@vibe-cafe/vibe-usage 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/package.json +1 -1
- package/src/codearts-roots.js +60 -0
- package/src/parsers/codearts-agent.js +184 -0
- package/src/parsers/cursor.js +19 -1
- package/src/parsers/droid.js +50 -5
- package/src/parsers/grok.js +102 -0
- package/src/parsers/index.js +2 -0
- package/src/state.js +73 -9
- package/src/sync.js +19 -6
- package/src/tools.js +33 -0
package/README.md
CHANGED
|
@@ -76,6 +76,7 @@ Quota results are never uploaded or added to incremental sync state. Credential-
|
|
|
76
76
|
| Alma | Electron app-data `alma/chat_threads.db` (macOS: `~/Library/Application Support/alma/chat_threads.db`; fixture/relocation override: `VIBE_USAGE_ALMA_DB`). Reads the `usage_records` ledger plus workspace names without selecting chat bodies, message metadata, provider credentials, or full workspace paths. Provider-prefixed model identifiers are normalized to their final model segment. Cache writes are included in input usage. The ledger contains assistant responses only, so Alma emits token buckets without session timing. |
|
|
77
77
|
| Claude Code + Claude Desktop Code/Cowork | Claude Code data in `~/.claude/projects/` (tokens + sessions) and `~/.claude/transcripts/` (sessions only), plus Claude Desktop Cowork's per-session `.claude/projects/` directories. Also scans `$CLAUDE_CONFIG_DIR` and data-bearing `~/.claude-*` profiles. All variants use the existing `claude-code` source; the parser selects the most complete copy of each session so shared/copied transcripts are not counted twice. Logs are streamed and cache creation tokens are included in input usage. |
|
|
78
78
|
| Cindy | Per-owner SQLite ledgers in the two regional Electron user-data roots: macOS `~/Library/Application Support/{CindyGlobal,Cindy}/cindy-*.db`, Windows `%APPDATA%\{CindyGlobal,Cindy}\cindy-*.db`, Linux `${XDG_CONFIG_HOME:-~/.config}/{CindyGlobal,Cindy}/cindy-*.db` (fixture/relocation override: `VIBE_USAGE_CINDY_DIRS`). Cindy-launched Claude Code already writes ordinary `~/.claude` transcripts, so it remains attributed to **Claude Code** and is not read again. Cindy's otherwise-private Codex and Pi daily/model ledger rows augment the existing **Codex** and **pi** sources. Currency rows are summed and cache creation joins input; chat messages, credentials, costs, and owner ids are never selected. The ledger adds token buckets only, without project or session timing. |
|
|
79
|
+
| CodeArts Agent (CodeArts Doer for Coding) | The embedded agent kernel's WAL SQLite store at `~/.codeartsdoer/codearts-data/opencode.db` (fixture/relocation override: `VIBE_USAGE_CODEARTS_AGENT_DIRS`, whose entries may be the database, `codearts-data`, or its `.codeartsdoer` parent). Reads only allow-listed identity/timing/model/project/token fields from `message` joined to `session`; prompt text, tool payloads, errors, costs, credentials and account/quota data are never selected. Input, output, cache reads and reasoning are separate counters; an untyped cache write is priced as the cheaper 5m cache-creation bucket rather than folded into input, since the store gives no per-TTL breakdown to price it more precisely. Child-agent calls keep contributing token usage, while their injected user messages fold into the top-level logical session instead of inflating human prompt/session counts. Copied stores deduplicate by session + message id, and lock/read failures use a disposable WAL snapshot or preserve prior incremental state. |
|
|
79
80
|
| Codex CLI | `$CODEX_HOME/sessions/` and `$CODEX_HOME/archived_sessions/` (default `~/.codex`), plus an optional temporary `--extra-codex-home`, legacy `codexExtraHome`, or explicitly added Codex/Multica roots; a versioned local index avoids re-reading unchanged rollouts and reads only safe append tails for ordinary sessions, same-session continuation files are combined without counting exact live/archive/cross-root copies twice, and fork/sub-agent replay remains excluded |
|
|
80
81
|
| Cola | `~/.cola/sessions/<scope>/*.jsonl` (or `$COLA_DATA_DIR/sessions/`), verified with Cola 1.4.4. Reads assistant token usage and session timing through the shared Pi reader; cache writes join input, cache reads remain separate, and reasoning is split from output. Copied transcripts with new session headers are deduplicated using the original record metadata and attributed to the earliest available session copy. Project names come from `cwd`, never channel/scope names. |
|
|
81
82
|
| Grok | `$GROK_HOME/sessions/<encoded-cwd>/<session-id>/` (default `~/.grok`) plus explicitly added Grok Homes; token usage from `updates.jsonl` `turn_completed.usage` (per-model `modelUsage`, cache reads, reasoning); project from `summary.json` cwd; copied sessions keep the more complete local record |
|
|
@@ -249,6 +250,10 @@ For OpenCode, each root uses its SQLite database when present; only roots withou
|
|
|
249
250
|
|
|
250
251
|
The first `npx @vibe-cafe/vibe-usage` run installs a user-level service (systemd on Linux, launchd on macOS, Task Scheduler on Windows — no admin rights needed) that syncs every 30 minutes and starts automatically on login. Nothing else to do.
|
|
251
252
|
|
|
253
|
+
Switching the CLI to a different account (running `init` again, or `config set apiKey`) rebinds the upload state, so the next sync re-uploads your full local history to the new account instead of treating it as already sent.
|
|
254
|
+
|
|
255
|
+
换绑账号后(重新 `init` 或 `config set apiKey`),下一次同步会自动全量重传本地历史,不会因为旧账号的同步记录而漏传。
|
|
256
|
+
|
|
252
257
|
<details>
|
|
253
258
|
<summary>Managing the service, and how it is launched</summary>
|
|
254
259
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { accessSync, constants, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { basename, delimiter, join, resolve } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
|
|
5
|
+
function normalizeRoot(path, home) {
|
|
6
|
+
if (path === '~') return home;
|
|
7
|
+
if (path.startsWith('~/') || path.startsWith('~\\')) return join(home, path.slice(2));
|
|
8
|
+
return resolve(path);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function resolveCodeartsAgentRoots(env = process.env, home = homedir()) {
|
|
12
|
+
const override = env.VIBE_USAGE_CODEARTS_AGENT_DIRS?.trim();
|
|
13
|
+
if (!override) return [join(home, '.codeartsdoer', 'codearts-data')];
|
|
14
|
+
return override.split(delimiter)
|
|
15
|
+
.map(path => path.trim())
|
|
16
|
+
.filter(Boolean)
|
|
17
|
+
.map(path => normalizeRoot(path, home));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function findDb(root) {
|
|
21
|
+
const stat = statSync(root);
|
|
22
|
+
const candidates = stat.isFile()
|
|
23
|
+
? [root]
|
|
24
|
+
: [join(root, 'opencode.db'), join(root, 'codearts-data', 'opencode.db')];
|
|
25
|
+
|
|
26
|
+
for (const candidate of candidates) {
|
|
27
|
+
try {
|
|
28
|
+
if (!statSync(candidate).isFile()) continue;
|
|
29
|
+
if (basename(candidate) !== 'opencode.db') continue;
|
|
30
|
+
accessSync(candidate, constants.R_OK);
|
|
31
|
+
return realpathSync(candidate);
|
|
32
|
+
} catch (err) {
|
|
33
|
+
if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') throw err;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Locate CodeArts Agent's accounting databases. An override may point at the
|
|
41
|
+
* data directory, its `.codeartsdoer` parent, or `opencode.db` itself. The
|
|
42
|
+
* default matches the home-relative location used by the desktop agent kernel;
|
|
43
|
+
* the override also covers relocated and platform-specific profiles.
|
|
44
|
+
*/
|
|
45
|
+
export function findCodeartsAgentDbs({ env = process.env, home = homedir(), onWarning = () => {} } = {}) {
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
const dbs = [];
|
|
48
|
+
for (const root of resolveCodeartsAgentRoots(env, home)) {
|
|
49
|
+
try {
|
|
50
|
+
const db = findDb(root);
|
|
51
|
+
if (!db || seen.has(db)) continue;
|
|
52
|
+
seen.add(db);
|
|
53
|
+
dbs.push(db);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') continue;
|
|
56
|
+
onWarning(`CodeArts Agent: 无法读取数据目录 ${root}: ${err.message}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return dbs;
|
|
60
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { projectFromCwd, toCount } from './fs-utils.js';
|
|
2
|
+
import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
3
|
+
import {
|
|
4
|
+
isSqliteUnavailableError,
|
|
5
|
+
queryDbJsonSnapshotOnLock,
|
|
6
|
+
sqliteUnavailableError,
|
|
7
|
+
} from './sqlite.js';
|
|
8
|
+
import { findCodeartsAgentDbs } from '../codearts-roots.js';
|
|
9
|
+
|
|
10
|
+
const SOURCE = 'codearts-agent';
|
|
11
|
+
|
|
12
|
+
// CodeArts Agent 26.9.101 uses an OpenCode-derived WAL database, but it is a
|
|
13
|
+
// separate product/account and therefore a separate Vibe Usage source. The
|
|
14
|
+
// recursive map folds child-agent sessions into their top-level user session;
|
|
15
|
+
// all child model calls remain billable while their injected `user` messages
|
|
16
|
+
// never become human prompts. Only identity, timing, model, project and token
|
|
17
|
+
// counters are selected. Message/part content, tools, errors, costs, account
|
|
18
|
+
// data and the CodeArts JSON/log files stay unread.
|
|
19
|
+
const USAGE_SQL = `
|
|
20
|
+
WITH RECURSIVE session_tree(
|
|
21
|
+
sessionId, logicalSessionId, logicalDirectory, isChild, trail
|
|
22
|
+
) AS (
|
|
23
|
+
SELECT s.id, s.id, s.directory, 0, ',' || s.id || ','
|
|
24
|
+
FROM session AS s
|
|
25
|
+
WHERE s.parent_id IS NULL
|
|
26
|
+
OR NOT EXISTS (SELECT 1 FROM session AS p WHERE p.id = s.parent_id)
|
|
27
|
+
UNION ALL
|
|
28
|
+
SELECT s.id, t.logicalSessionId, t.logicalDirectory, 1,
|
|
29
|
+
t.trail || s.id || ','
|
|
30
|
+
FROM session AS s
|
|
31
|
+
JOIN session_tree AS t ON s.parent_id = t.sessionId
|
|
32
|
+
WHERE instr(t.trail, ',' || s.id || ',') = 0
|
|
33
|
+
), session_map AS (
|
|
34
|
+
SELECT
|
|
35
|
+
s.id AS sessionId,
|
|
36
|
+
coalesce(t.logicalSessionId, s.id) AS logicalSessionId,
|
|
37
|
+
coalesce(t.logicalDirectory, s.directory) AS logicalDirectory,
|
|
38
|
+
s.directory AS physicalDirectory,
|
|
39
|
+
coalesce(t.isChild, 0) AS isChild
|
|
40
|
+
FROM session AS s
|
|
41
|
+
LEFT JOIN session_tree AS t ON t.sessionId = s.id
|
|
42
|
+
)
|
|
43
|
+
SELECT
|
|
44
|
+
m.id AS messageId,
|
|
45
|
+
m.session_id AS physicalSessionId,
|
|
46
|
+
coalesce(sm.logicalSessionId, m.session_id) AS logicalSessionId,
|
|
47
|
+
coalesce(sm.isChild, 0) AS isChild,
|
|
48
|
+
sm.logicalDirectory AS logicalDirectory,
|
|
49
|
+
sm.physicalDirectory AS physicalDirectory,
|
|
50
|
+
m.time_created AS columnCreated,
|
|
51
|
+
json_extract(m.data, '$.role') AS role,
|
|
52
|
+
json_extract(m.data, '$.time.created') AS dataCreated,
|
|
53
|
+
coalesce(
|
|
54
|
+
json_extract(m.data, '$.modelID'),
|
|
55
|
+
json_extract(m.data, '$.modelId'),
|
|
56
|
+
json_extract(m.data, '$.model.modelID'),
|
|
57
|
+
json_extract(m.data, '$.model.modelId')
|
|
58
|
+
) AS model,
|
|
59
|
+
json_extract(m.data, '$.path.root') AS rootPath,
|
|
60
|
+
json_extract(m.data, '$.path.cwd') AS cwdPath,
|
|
61
|
+
json_extract(m.data, '$.tokens.input') AS inputTokens,
|
|
62
|
+
json_extract(m.data, '$.tokens.output') AS outputTokens,
|
|
63
|
+
json_extract(m.data, '$.tokens.cache.read') AS cacheReadTokens,
|
|
64
|
+
json_extract(m.data, '$.tokens.cache.write') AS cacheWriteTokens,
|
|
65
|
+
json_extract(m.data, '$.tokens.reasoning') AS reasoningTokens
|
|
66
|
+
FROM message AS m
|
|
67
|
+
LEFT JOIN session_map AS sm ON sm.sessionId = m.session_id
|
|
68
|
+
ORDER BY m.time_created, m.id
|
|
69
|
+
`;
|
|
70
|
+
|
|
71
|
+
function timestampFrom(value, fallback) {
|
|
72
|
+
const raw = value ?? fallback;
|
|
73
|
+
if (raw == null) return null;
|
|
74
|
+
const number = Number(raw);
|
|
75
|
+
let date;
|
|
76
|
+
if (Number.isFinite(number) && number > 0) {
|
|
77
|
+
date = new Date(number < 1e12 ? number * 1000 : number);
|
|
78
|
+
} else if (typeof raw === 'string') {
|
|
79
|
+
date = new Date(raw);
|
|
80
|
+
} else {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function tokenFootprint(row) {
|
|
87
|
+
return toCount(row.inputTokens) + toCount(row.outputTokens)
|
|
88
|
+
+ toCount(row.cacheReadTokens) + toCount(row.cacheWriteTokens)
|
|
89
|
+
+ toCount(row.reasoningTokens);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function projectFor(row) {
|
|
93
|
+
return projectFromCwd(
|
|
94
|
+
row.rootPath || row.cwdPath || row.logicalDirectory || row.physicalDirectory,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function parse() {
|
|
99
|
+
const warnings = [];
|
|
100
|
+
const dbs = findCodeartsAgentDbs({ onWarning: message => warnings.push(message) });
|
|
101
|
+
const records = new Map();
|
|
102
|
+
|
|
103
|
+
for (const dbPath of dbs) {
|
|
104
|
+
let rows;
|
|
105
|
+
try {
|
|
106
|
+
rows = queryDbJsonSnapshotOnLock(dbPath, USAGE_SQL, {
|
|
107
|
+
tempPrefix: 'vibe-usage-codearts-agent',
|
|
108
|
+
});
|
|
109
|
+
} catch (err) {
|
|
110
|
+
if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('CodeArts Agent');
|
|
111
|
+
warnings.push(`CodeArts Agent: 无法读取 ${dbPath}: ${err.message}`);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const [index, row] of rows.entries()) {
|
|
116
|
+
const timestamp = timestampFrom(row.dataCreated, row.columnCreated);
|
|
117
|
+
if (!timestamp) continue;
|
|
118
|
+
const sessionId = row.physicalSessionId == null ? '' : String(row.physicalSessionId);
|
|
119
|
+
const messageId = row.messageId == null ? '' : String(row.messageId);
|
|
120
|
+
const key = sessionId && messageId
|
|
121
|
+
? JSON.stringify([sessionId, messageId])
|
|
122
|
+
: JSON.stringify([dbPath, index]);
|
|
123
|
+
const candidate = { ...row, timestamp };
|
|
124
|
+
const previous = records.get(key);
|
|
125
|
+
if (!previous || tokenFootprint(candidate) > tokenFootprint(previous)) {
|
|
126
|
+
records.set(key, candidate);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// A partial multi-profile read would look like legitimate deletion to the
|
|
132
|
+
// incremental sync layer. Suppress the entire source until every discovered
|
|
133
|
+
// database can be read again.
|
|
134
|
+
if (warnings.length) return { buckets: [], sessions: [], skipped: true, warnings };
|
|
135
|
+
|
|
136
|
+
const entries = [];
|
|
137
|
+
const events = [];
|
|
138
|
+
const sessionsWithUserPrompt = new Set();
|
|
139
|
+
|
|
140
|
+
for (const row of records.values()) {
|
|
141
|
+
const project = projectFor(row);
|
|
142
|
+
const logicalSessionId = String(row.logicalSessionId || row.physicalSessionId || 'unknown');
|
|
143
|
+
if (row.role) {
|
|
144
|
+
const isHumanPrompt = row.role === 'user' && !row.isChild;
|
|
145
|
+
events.push({
|
|
146
|
+
sessionId: logicalSessionId,
|
|
147
|
+
source: SOURCE,
|
|
148
|
+
project,
|
|
149
|
+
timestamp: row.timestamp,
|
|
150
|
+
role: isHumanPrompt ? 'user' : 'assistant',
|
|
151
|
+
});
|
|
152
|
+
if (isHumanPrompt) sessionsWithUserPrompt.add(logicalSessionId);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (row.role !== 'assistant') continue;
|
|
156
|
+
const inputTokens = toCount(row.inputTokens);
|
|
157
|
+
const outputTokens = toCount(row.outputTokens);
|
|
158
|
+
const cachedInputTokens = toCount(row.cacheReadTokens);
|
|
159
|
+
const reasoningOutputTokens = toCount(row.reasoningTokens);
|
|
160
|
+
// The store only gives one cache-write total with no per-TTL breakdown,
|
|
161
|
+
// so an untyped write is priced as the cheaper 5m bucket rather than
|
|
162
|
+
// folded into input: better to undercharge than overcharge an unknown TTL.
|
|
163
|
+
const cacheCreation5mTokens = toCount(row.cacheWriteTokens);
|
|
164
|
+
if (inputTokens + outputTokens + cachedInputTokens + reasoningOutputTokens
|
|
165
|
+
+ cacheCreation5mTokens === 0) continue;
|
|
166
|
+
|
|
167
|
+
entries.push({
|
|
168
|
+
source: SOURCE,
|
|
169
|
+
model: row.model || 'unknown',
|
|
170
|
+
project,
|
|
171
|
+
timestamp: row.timestamp,
|
|
172
|
+
inputTokens,
|
|
173
|
+
outputTokens,
|
|
174
|
+
cachedInputTokens,
|
|
175
|
+
reasoningOutputTokens,
|
|
176
|
+
cacheCreation5mTokens,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
buckets: aggregateToBuckets(entries),
|
|
182
|
+
sessions: extractSessions(events.filter(event => sessionsWithUserPrompt.has(event.sessionId))),
|
|
183
|
+
};
|
|
184
|
+
}
|
package/src/parsers/cursor.js
CHANGED
|
@@ -264,7 +264,25 @@ export async function parse() {
|
|
|
264
264
|
const cacheReadIdx = idx('Cache Read');
|
|
265
265
|
const outputIdx = idx('Output Tokens');
|
|
266
266
|
|
|
267
|
-
|
|
267
|
+
// A renamed column used to degrade silently: every row then parsed as zero
|
|
268
|
+
// tokens, the run uploaded an empty snapshot, and sync.js pruned Cursor's
|
|
269
|
+
// incremental state as if the account had gone quiet — the dashboard just
|
|
270
|
+
// stopped counting Cursor. Treat a header we do not recognise as a skipped
|
|
271
|
+
// run instead: the state survives and the user gets a warning.
|
|
272
|
+
const hasTokenColumn =
|
|
273
|
+
inputCacheWriteIdx >= 0 || inputNoCacheIdx >= 0 || cacheReadIdx >= 0 || outputIdx >= 0;
|
|
274
|
+
if (dateIdx < 0 || modelIdx < 0 || !hasTokenColumn) {
|
|
275
|
+
return {
|
|
276
|
+
buckets: [],
|
|
277
|
+
sessions: [],
|
|
278
|
+
skipped: true,
|
|
279
|
+
warnings: [
|
|
280
|
+
'cursor: 导出表头与预期不符(需要 Date、Model 及至少一个 token 列),本次已跳过以保护增量状态;' +
|
|
281
|
+
'Cursor 可能更改了导出接口,请反馈。' +
|
|
282
|
+
`当前表头: ${header.slice(0, 8).join(', ')}`,
|
|
283
|
+
],
|
|
284
|
+
};
|
|
285
|
+
}
|
|
268
286
|
|
|
269
287
|
const entries = [];
|
|
270
288
|
for (let r = 1; r < rows.length; r++) {
|
package/src/parsers/droid.js
CHANGED
|
@@ -1,9 +1,45 @@
|
|
|
1
1
|
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
|
2
2
|
import { join, basename, dirname } from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import { getDroidSessionsDir, getDroidSettingsPaths } from '../tools.js';
|
|
4
4
|
import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
5
|
+
import { readJsonSafe } from './fs-utils.js';
|
|
6
|
+
|
|
7
|
+
// Factory session sidecars store the local slot id (`custom:gpt-6-astra-[gw]-0`),
|
|
8
|
+
// not the API model. `customModels[].model` is what the provider actually sees.
|
|
9
|
+
// A bare routing word would collide with Cursor's pricing entry (PR #83).
|
|
10
|
+
const ROUTING_TIER_IDS = new Set([
|
|
11
|
+
'auto', 'default', 'default-model', 'fast', 'turbo', 'lite', 'ultimate', 'performance', 'efficient',
|
|
12
|
+
]);
|
|
13
|
+
const CUSTOM_SLOT_ID = /^custom:(.+)-\[([^\]]+)\]-(\d+)$/;
|
|
14
|
+
|
|
15
|
+
export function loadDroidCustomModelCatalog() {
|
|
16
|
+
const catalog = new Map();
|
|
17
|
+
for (const settingsPath of getDroidSettingsPaths()) {
|
|
18
|
+
const data = readJsonSafe(settingsPath);
|
|
19
|
+
if (!data) continue;
|
|
20
|
+
for (const list of [data.customModels, data.custom_models]) {
|
|
21
|
+
if (!Array.isArray(list)) continue;
|
|
22
|
+
for (const entry of list) {
|
|
23
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
24
|
+
const id = typeof entry.id === 'string' ? entry.id.trim() : '';
|
|
25
|
+
const model = typeof entry.model === 'string' ? entry.model.trim() : '';
|
|
26
|
+
if (id && model) catalog.set(id, model);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return catalog;
|
|
31
|
+
}
|
|
5
32
|
|
|
6
|
-
|
|
33
|
+
export function resolveDroidModel(raw, catalog = new Map()) {
|
|
34
|
+
const id = typeof raw === 'string' ? raw.trim() : '';
|
|
35
|
+
if (!id) return 'unknown';
|
|
36
|
+
const mapped = catalog instanceof Map ? catalog.get(id) : undefined;
|
|
37
|
+
const resolved = (typeof mapped === 'string' && mapped.trim())
|
|
38
|
+
? mapped.trim()
|
|
39
|
+
: (id.match(CUSTOM_SLOT_ID)?.[1] || id);
|
|
40
|
+
const lower = resolved.toLowerCase();
|
|
41
|
+
return ROUTING_TIER_IDS.has(lower) ? `droid-${lower}` : resolved;
|
|
42
|
+
}
|
|
7
43
|
|
|
8
44
|
function findJsonlFiles(dir) {
|
|
9
45
|
const results = [];
|
|
@@ -37,7 +73,8 @@ function toSafeNumber(value) {
|
|
|
37
73
|
export async function parse() {
|
|
38
74
|
const entries = [];
|
|
39
75
|
const sessionEvents = [];
|
|
40
|
-
const sessionFiles = findJsonlFiles(
|
|
76
|
+
const sessionFiles = findJsonlFiles(getDroidSessionsDir());
|
|
77
|
+
const catalog = loadDroidCustomModelCatalog();
|
|
41
78
|
|
|
42
79
|
for (const filePath of sessionFiles) {
|
|
43
80
|
const sessionId = basename(filePath, '.jsonl');
|
|
@@ -92,20 +129,28 @@ export async function parse() {
|
|
|
92
129
|
const tokenUsage = settings?.tokenUsage;
|
|
93
130
|
if (!tokenUsage) continue;
|
|
94
131
|
|
|
132
|
+
// Factory already stores uncached prompt in inputTokens. Its session log
|
|
133
|
+
// records `inputTokens` + `cacheReadInputTokens` = `totalInputTokens`;
|
|
134
|
+
// subtracting cacheReadTokens here zeros BYOK input whenever cache > input.
|
|
95
135
|
const cacheReadTokens = toSafeNumber(tokenUsage.cacheReadTokens);
|
|
96
136
|
const thinkingTokens = toSafeNumber(tokenUsage.thinkingTokens);
|
|
97
|
-
const
|
|
137
|
+
const cacheCreation5mTokens = toSafeNumber(tokenUsage.cacheCreationTokens);
|
|
138
|
+
const inputTokens = toSafeNumber(tokenUsage.inputTokens);
|
|
98
139
|
const outputTokens = Math.max(0, toSafeNumber(tokenUsage.outputTokens) - thinkingTokens);
|
|
140
|
+
if (inputTokens + outputTokens + cacheReadTokens + thinkingTokens + cacheCreation5mTokens === 0) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
99
143
|
|
|
100
144
|
entries.push({
|
|
101
145
|
source: 'droid',
|
|
102
|
-
model: settings.model
|
|
146
|
+
model: resolveDroidModel(settings.model, catalog),
|
|
103
147
|
project,
|
|
104
148
|
timestamp: firstMessageTimestamp,
|
|
105
149
|
inputTokens,
|
|
106
150
|
outputTokens,
|
|
107
151
|
cachedInputTokens: cacheReadTokens,
|
|
108
152
|
reasoningOutputTokens: thinkingTokens,
|
|
153
|
+
cacheCreation5mTokens,
|
|
109
154
|
});
|
|
110
155
|
}
|
|
111
156
|
|
package/src/parsers/grok.js
CHANGED
|
@@ -148,6 +148,62 @@ async function forEachJsonlLine(filePath, onLine, strict = false) {
|
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Grok 1.0 moved per-turn token accounting out of the ACP stream into a
|
|
153
|
+
* dedicated `<session>/usage.json` ledger — `grok usage <session-id>` is its
|
|
154
|
+
* documented reader, and the sessions guide says to use that "instead of
|
|
155
|
+
* reading session files". Older builds (0.2.x) wrote the same numbers into
|
|
156
|
+
* `updates.jsonl` `turn_completed.usage`; those sessions have no usage.json.
|
|
157
|
+
*
|
|
158
|
+
* The ledger holds token totals per turn but neither timestamps nor a model
|
|
159
|
+
* id, so callers pair its turns with the session's `turn_completed` events by
|
|
160
|
+
* order and fall back to the summary's model.
|
|
161
|
+
*/
|
|
162
|
+
function readUsageLedger(sessionPath, strict = false) {
|
|
163
|
+
const ledgerPath = join(sessionPath, 'usage.json');
|
|
164
|
+
if (!existsSync(ledgerPath)) return null;
|
|
165
|
+
let parsed;
|
|
166
|
+
try {
|
|
167
|
+
parsed = JSON.parse(readFileSync(ledgerPath, 'utf-8'));
|
|
168
|
+
} catch (err) {
|
|
169
|
+
if (strict) throw err;
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
173
|
+
const turns = Array.isArray(parsed.turns) ? parsed.turns : [];
|
|
174
|
+
const session = parsed.session && typeof parsed.session === 'object' ? parsed.session : null;
|
|
175
|
+
if (turns.length === 0 && !session) return null;
|
|
176
|
+
return { session, turns };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Map one ledger record (turn or session totals) onto the usage shape the
|
|
180
|
+
* ACP stream uses. Cache writes are folded into input — Grok publishes no
|
|
181
|
+
* separate cache-write rate and pre-1.0 those tokens were part of
|
|
182
|
+
* `inputTokens`, so folding keeps cross-version totals identical and leaves
|
|
183
|
+
* the Anthropic-only cache-creation columns untouched. Any per-model
|
|
184
|
+
* `modelUsage` map is carried through (and folded the same way) so the
|
|
185
|
+
* caller's model attribution works exactly like the ACP path. */
|
|
186
|
+
function ledgerRecordUsage(record) {
|
|
187
|
+
if (!record || typeof record !== 'object') return null;
|
|
188
|
+
const fold = (entry) => {
|
|
189
|
+
if (!entry || typeof entry !== 'object') return entry;
|
|
190
|
+
const input = Math.max(0, Number(entry.inputTokens) || 0);
|
|
191
|
+
const cacheCreation = Math.max(0, Number(entry.cacheCreationTokens) || 0);
|
|
192
|
+
return { ...entry, inputTokens: input + cacheCreation };
|
|
193
|
+
};
|
|
194
|
+
const folded = fold(record);
|
|
195
|
+
const modelUsage = record.modelUsage && typeof record.modelUsage === 'object'
|
|
196
|
+
? Object.fromEntries(Object.entries(record.modelUsage).map(([model, entry]) => [model, fold(entry)]))
|
|
197
|
+
: null;
|
|
198
|
+
const usage = { ...folded, ...(modelUsage ? { modelUsage } : {}) };
|
|
199
|
+
const total =
|
|
200
|
+
Number(usage.inputTokens || 0) +
|
|
201
|
+
Number(usage.cachedReadTokens || 0) +
|
|
202
|
+
Number(usage.outputTokens || 0) +
|
|
203
|
+
Number(usage.reasoningTokens || 0);
|
|
204
|
+
return total > 0 ? usage : null;
|
|
205
|
+
}
|
|
206
|
+
|
|
151
207
|
function listSessionDirs(sessionsDir, strict = false) {
|
|
152
208
|
const results = [];
|
|
153
209
|
if (!existsSync(sessionsDir)) {
|
|
@@ -231,6 +287,7 @@ export async function parse({ extraRoots = [] } = {}) {
|
|
|
231
287
|
|
|
232
288
|
const entries = [];
|
|
233
289
|
const sessionEvents = [];
|
|
290
|
+
const unreadSessions = [];
|
|
234
291
|
|
|
235
292
|
const candidates = [];
|
|
236
293
|
for (const sessionsDir of roots) {
|
|
@@ -287,9 +344,14 @@ export async function parse({ extraRoots = [] } = {}) {
|
|
|
287
344
|
const cwd = summary.info?.cwd || summary.git_root_dir || null;
|
|
288
345
|
const project = cwd ? projectFromPath(cwd) : projectFallback;
|
|
289
346
|
const fallbackModel = summary.current_model_id || 'unknown';
|
|
347
|
+
const sessionEntryStart = entries.length;
|
|
290
348
|
|
|
291
349
|
// Prefer updates.jsonl turn_completed for exact usage + message timings.
|
|
350
|
+
// `turnTimestamps` keeps their order so a 1.x usage.json ledger (which
|
|
351
|
+
// has no timestamps of its own) can be paired turn-by-turn below.
|
|
292
352
|
let sawUserOrAssistant = false;
|
|
353
|
+
let usageFromUpdates = 0;
|
|
354
|
+
const turnTimestamps = [];
|
|
293
355
|
await forEachJsonlLine(join(sessionPath, 'updates.jsonl'), (obj) => {
|
|
294
356
|
const update = obj?.params?.update;
|
|
295
357
|
if (!update || typeof update !== 'object') return;
|
|
@@ -298,12 +360,15 @@ export async function parse({ extraRoots = [] } = {}) {
|
|
|
298
360
|
const timestamp = toDate(obj.timestamp);
|
|
299
361
|
|
|
300
362
|
if (kind === 'turn_completed' && timestamp) {
|
|
363
|
+
turnTimestamps.push(timestamp);
|
|
364
|
+
const before = entries.length;
|
|
301
365
|
emitTurnUsage(entries, {
|
|
302
366
|
usage: update.usage,
|
|
303
367
|
project,
|
|
304
368
|
timestamp,
|
|
305
369
|
fallbackModel,
|
|
306
370
|
});
|
|
371
|
+
if (entries.length > before) usageFromUpdates += 1;
|
|
307
372
|
}
|
|
308
373
|
|
|
309
374
|
if (!timestamp) return;
|
|
@@ -329,6 +394,37 @@ export async function parse({ extraRoots = [] } = {}) {
|
|
|
329
394
|
}
|
|
330
395
|
}, strict);
|
|
331
396
|
|
|
397
|
+
// 1.x sessions keep per-turn totals in usage.json instead of the ACP
|
|
398
|
+
// stream. Consulted only when updates.jsonl yielded no usage, so a
|
|
399
|
+
// session that carries both is never counted twice.
|
|
400
|
+
if (usageFromUpdates === 0) {
|
|
401
|
+
const ledger = readUsageLedger(sessionPath, strict);
|
|
402
|
+
const records = ledger?.turns?.length ? ledger.turns : (ledger?.session ? [ledger.session] : []);
|
|
403
|
+
if (records.length > 0) {
|
|
404
|
+
const sessionTimestamp = toDate(summary.updated_at || summary.last_active_at || summary.created_at);
|
|
405
|
+
for (const [index, record] of records.entries()) {
|
|
406
|
+
const usage = ledgerRecordUsage(record);
|
|
407
|
+
if (!usage) continue;
|
|
408
|
+
const timestamp = turnTimestamps[index] || sessionTimestamp;
|
|
409
|
+
if (!timestamp) continue;
|
|
410
|
+
emitTurnUsage(entries, { usage, project, timestamp, fallbackModel });
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Canary for the next on-disk format move: a session whose signals.json
|
|
416
|
+
// reports completed turns (and a model) but whose usage we could not read
|
|
417
|
+
// from either source is a silent collection gap, not an idle session —
|
|
418
|
+
// 1.0 moved the ledger to usage.json exactly like this.
|
|
419
|
+
if (entries.length === sessionEntryStart) {
|
|
420
|
+
const signals = readJsonSafe(join(sessionPath, 'signals.json'));
|
|
421
|
+
const turnCount = Number(signals?.turnCount) || 0;
|
|
422
|
+
const modelsUsed = Array.isArray(signals?.modelsUsed) ? signals.modelsUsed.filter(Boolean) : [];
|
|
423
|
+
if (turnCount > 0 && modelsUsed.length > 0) {
|
|
424
|
+
unreadSessions.push({ sessionId, turnCount });
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
332
428
|
// Fallback timing from events.jsonl when updates lack message chunks
|
|
333
429
|
// (short/aborted sessions, older builds).
|
|
334
430
|
if (!sawUserOrAssistant) {
|
|
@@ -391,5 +487,11 @@ export async function parse({ extraRoots = [] } = {}) {
|
|
|
391
487
|
return {
|
|
392
488
|
buckets: aggregateToBuckets(entries),
|
|
393
489
|
sessions: extractSessions(sessionEvents),
|
|
490
|
+
...(unreadSessions.length > 0 && {
|
|
491
|
+
warnings: [
|
|
492
|
+
`grok: ${unreadSessions.length} 个会话有已完成轮次但未读到用量(例如 ${unreadSessions[0].sessionId},${unreadSessions[0].turnCount} 轮),` +
|
|
493
|
+
'可能是 Grok 又更改了用量落盘格式,请反馈;本次未上传这些会话的用量。',
|
|
494
|
+
],
|
|
495
|
+
}),
|
|
394
496
|
};
|
|
395
497
|
}
|
package/src/parsers/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { parse as parseClaudeCode } from './claude-code.js';
|
|
2
2
|
import { parse as parseCline } from './cline.js';
|
|
3
|
+
import { parse as parseCodeartsAgent } from './codearts-agent.js';
|
|
3
4
|
import { parse as parseCodex } from './codex.js';
|
|
4
5
|
import { parse as parseCopilotCli } from './copilot-cli.js';
|
|
5
6
|
import { parse as parseCraftAgent } from './craft-agent.js';
|
|
@@ -33,6 +34,7 @@ import { parse as parseCodebuddy } from './codebuddy.js';
|
|
|
33
34
|
|
|
34
35
|
export const parsers = {
|
|
35
36
|
'claude-code': parseClaudeCode,
|
|
37
|
+
'codearts-agent': parseCodeartsAgent,
|
|
36
38
|
'codex': parseCodex,
|
|
37
39
|
'cola': parseCola,
|
|
38
40
|
'grok': parseGrok,
|
package/src/state.js
CHANGED
|
@@ -8,6 +8,8 @@ import { createHash, randomBytes } from 'node:crypto';
|
|
|
8
8
|
// already uploaded successfully. Lets each sync skip re-sending unchanged
|
|
9
9
|
// history: parsers stay stateless (still parse everything from disk every run),
|
|
10
10
|
// but only new/changed items hit the network.
|
|
11
|
+
// The file also records the upload target it belongs to (`identity`), so state
|
|
12
|
+
// left by a previous account can never suppress that account's history.
|
|
11
13
|
// VIBE_USAGE_STATE_DIR overrides the dir (test hook).
|
|
12
14
|
const STATE_DIR = process.env.VIBE_USAGE_STATE_DIR?.trim() || join(homedir(), '.vibe-usage');
|
|
13
15
|
const isDev = process.env.VIBE_USAGE_DEV === '1';
|
|
@@ -17,14 +19,57 @@ export function getStatePath() {
|
|
|
17
19
|
return STATE_FILE;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
|
|
22
|
+
// The upload target this state belongs to: which server, and which account on
|
|
23
|
+
// it. state.json only records what was already uploaded *to that target*, so
|
|
24
|
+
// after a re-bind (`init` again, `config set apiKey`, or a desktop app
|
|
25
|
+
// rewriting config.json) the old hashes must not make sync skip history the new
|
|
26
|
+
// account has never received.
|
|
27
|
+
//
|
|
28
|
+
// The key is stored only as a fingerprint. The raw apiKey must never appear in
|
|
29
|
+
// state.json — it is an ordinary-permission file next to the parser state, not
|
|
30
|
+
// a credential store; config.json (mode 0600) remains the only place it lives.
|
|
31
|
+
export function stateIdentity({ apiUrl, apiKey } = {}) {
|
|
32
|
+
return {
|
|
33
|
+
apiUrl: apiUrl || '',
|
|
34
|
+
keyFingerprint: apiKey
|
|
35
|
+
? createHash('sha256').update(String(apiKey)).digest('hex').slice(0, 16)
|
|
36
|
+
: '',
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isIdentity(value) {
|
|
41
|
+
return !!value
|
|
42
|
+
&& typeof value === 'object'
|
|
43
|
+
&& typeof value.apiUrl === 'string'
|
|
44
|
+
&& typeof value.keyFingerprint === 'string';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sameIdentity(a, b) {
|
|
48
|
+
return a.apiUrl === b.apiUrl && a.keyFingerprint === b.keyFingerprint;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// `identity` is optional: callers that only read the recorded counts (e.g.
|
|
52
|
+
// `status`) pass nothing and keep the pre-0.11.1 behaviour of taking the file
|
|
53
|
+
// at face value.
|
|
54
|
+
export function loadState(identity) {
|
|
21
55
|
if (!existsSync(STATE_FILE)) return { buckets: {}, sessions: {} };
|
|
22
56
|
try {
|
|
23
57
|
const parsed = JSON.parse(readFileSync(STATE_FILE, 'utf-8'));
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
58
|
+
const buckets = parsed.buckets ?? {};
|
|
59
|
+
const sessions = parsed.sessions ?? {};
|
|
60
|
+
if (!isIdentity(identity) || !isIdentity(parsed.identity)) {
|
|
61
|
+
// No identity recorded — written by a CLI older than 0.11.1. Adopt the
|
|
62
|
+
// entries as-is rather than forcing a re-upload: on upgrade day that
|
|
63
|
+
// would make every installed client re-send its whole history at once.
|
|
64
|
+
// The next saveState() stamps the current identity, so any later re-bind
|
|
65
|
+
// is caught.
|
|
66
|
+
return { buckets, sessions };
|
|
67
|
+
}
|
|
68
|
+
if (sameIdentity(parsed.identity, identity)) return { buckets, sessions };
|
|
69
|
+
// Bound to a different account or server: nothing recorded here was ever
|
|
70
|
+
// uploaded to the current target, so start empty and re-send local history.
|
|
71
|
+
// `identityChanged` is a runtime signal for the caller, never persisted.
|
|
72
|
+
return { buckets: {}, sessions: {}, identityChanged: true };
|
|
28
73
|
} catch {
|
|
29
74
|
// Corrupt/unreadable state must not lose data — treat as empty, which
|
|
30
75
|
// triggers a one-time full re-upload (same as a fresh install).
|
|
@@ -32,14 +77,28 @@ export function loadState() {
|
|
|
32
77
|
}
|
|
33
78
|
}
|
|
34
79
|
|
|
35
|
-
export function saveState(state) {
|
|
80
|
+
export function saveState(state, identity) {
|
|
36
81
|
mkdirSync(STATE_DIR, { recursive: true });
|
|
82
|
+
// Only the durable fields are written: `identityChanged` is loadState()'s
|
|
83
|
+
// one-run signal, not state. A CLI older than 0.11.1 reads just
|
|
84
|
+
// buckets/sessions, so the extra top-level `identity` key is ignored there —
|
|
85
|
+
// a file written by this version stays readable by older clients.
|
|
86
|
+
const payload = {
|
|
87
|
+
buckets: state.buckets ?? {},
|
|
88
|
+
sessions: state.sessions ?? {},
|
|
89
|
+
};
|
|
90
|
+
if (isIdentity(identity)) {
|
|
91
|
+
payload.identity = {
|
|
92
|
+
apiUrl: identity.apiUrl,
|
|
93
|
+
keyFingerprint: identity.keyFingerprint,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
37
96
|
// Atomic replace: write to a unique temp file then rename over the target.
|
|
38
97
|
// A crash mid-write can no longer truncate state.json into an unreadable
|
|
39
98
|
// file that loadState() would treat as empty (triggering a full re-upload).
|
|
40
99
|
const tempPath = `${STATE_FILE}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
|
41
100
|
try {
|
|
42
|
-
writeFileSync(tempPath, JSON.stringify(
|
|
101
|
+
writeFileSync(tempPath, JSON.stringify(payload) + '\n', 'utf-8');
|
|
43
102
|
renameSync(tempPath, STATE_FILE);
|
|
44
103
|
} finally {
|
|
45
104
|
// No-op after a successful rename (the temp file is already gone); cleans
|
|
@@ -62,8 +121,13 @@ export function clearState() {
|
|
|
62
121
|
}
|
|
63
122
|
|
|
64
123
|
// Composite key must mirror the server dedup key so a project rename or
|
|
65
|
-
// uploadProject toggle changes the key and naturally forces a re-send
|
|
66
|
-
//
|
|
124
|
+
// uploadProject toggle changes the key and naturally forces a re-send under
|
|
125
|
+
// the new name. Nothing retires the row uploaded under the OLD key: ingest
|
|
126
|
+
// only upserts on (user_id, source, model, project, hostname, bucket_start),
|
|
127
|
+
// and there is no server-side reconciliation pass. The stale row survives
|
|
128
|
+
// until `vibe-usage reset` or a one-off server-side cleanup, so a rename
|
|
129
|
+
// double-counts tokens on the dashboard in the meantime. Verified against the
|
|
130
|
+
// server 2026-09-19; weigh this before changing what a parser reports.
|
|
67
131
|
export function bucketKey(b) {
|
|
68
132
|
return `${b.source}|${b.model}|${b.project}|${b.hostname}|${b.bucketStart}`;
|
|
69
133
|
}
|
package/src/sync.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { hostname as osHostname } from 'node:os';
|
|
2
2
|
import { loadConfig, saveConfig } from './config.js';
|
|
3
3
|
import {
|
|
4
|
-
loadState, saveState, pruneState,
|
|
4
|
+
loadState, saveState, pruneState, stateIdentity,
|
|
5
5
|
bucketKey, bucketHash, sessionKey, sessionHash,
|
|
6
6
|
} from './state.js';
|
|
7
7
|
import { ingest, fetchSettings } from './api.js';
|
|
@@ -119,6 +119,13 @@ export async function runSync({
|
|
|
119
119
|
// Resolve it before parsing or loading upload state so failure is a true
|
|
120
120
|
// no-op: no data upload and no state mutation.
|
|
121
121
|
const apiUrl = config.apiUrl || 'https://vibecafe.ai';
|
|
122
|
+
// state.json records what was already uploaded to *this* account on *this*
|
|
123
|
+
// server. Passing the identity into loadState() makes state left by a
|
|
124
|
+
// previous account fall away, so a re-bind re-uploads the local history
|
|
125
|
+
// instead of diffing it against uploads the new account never received.
|
|
126
|
+
// Built from the same `apiUrl` the ingest calls below use, so the recorded
|
|
127
|
+
// target and the actual target can never drift apart.
|
|
128
|
+
const identity = stateIdentity({ apiUrl, apiKey: config.apiKey });
|
|
122
129
|
let uploadProject;
|
|
123
130
|
try {
|
|
124
131
|
const settings = await fetchSettings(apiUrl, config.apiKey);
|
|
@@ -223,11 +230,14 @@ export async function runSync({
|
|
|
223
230
|
// Successful parsers emitted no live items. Prune their old keys even on
|
|
224
231
|
// this fast path; otherwise deleting the final local log would leave dead
|
|
225
232
|
// state entries forever. Failed-parser sources remain protected.
|
|
226
|
-
const state = loadState();
|
|
233
|
+
const state = loadState(identity);
|
|
234
|
+
if (state.identityChanged && !quiet) {
|
|
235
|
+
console.log(dim('检测到上传账号已更换,本次全量重传本地历史'));
|
|
236
|
+
}
|
|
227
237
|
const before = Object.keys(state.buckets).length + Object.keys(state.sessions).length;
|
|
228
238
|
pruneState(state, new Set(), new Set(), okSources);
|
|
229
239
|
const pruned = before - (Object.keys(state.buckets).length + Object.keys(state.sessions).length);
|
|
230
|
-
if (pruned > 0) saveState(state);
|
|
240
|
+
if (pruned > 0) saveState(state, identity);
|
|
231
241
|
if (!quiet && parserProgress.length > 0) {
|
|
232
242
|
for (const p of parserProgress) {
|
|
233
243
|
console.log(dim(` ${p.source}: 正在建立本地索引 ${p.completed}/${p.total}(下次同步继续)`));
|
|
@@ -283,7 +293,10 @@ export async function runSync({
|
|
|
283
293
|
// an active one sends just the current 30-min bucket.
|
|
284
294
|
// Missing/corrupt state.json => empty maps => one-time full upload, then
|
|
285
295
|
// incremental forever after.
|
|
286
|
-
const state = loadState();
|
|
296
|
+
const state = loadState(identity);
|
|
297
|
+
if (state.identityChanged && !quiet) {
|
|
298
|
+
console.log(dim('检测到上传账号已更换,本次全量重传本地历史'));
|
|
299
|
+
}
|
|
287
300
|
const changedBuckets = [];
|
|
288
301
|
const changedSessions = [];
|
|
289
302
|
const liveBucketKeys = new Set();
|
|
@@ -321,7 +334,7 @@ export async function runSync({
|
|
|
321
334
|
const before = Object.keys(state.buckets).length + Object.keys(state.sessions).length;
|
|
322
335
|
pruneState(state, liveBucketKeys, liveSessionKeys, okSources);
|
|
323
336
|
const pruned = before - (Object.keys(state.buckets).length + Object.keys(state.sessions).length);
|
|
324
|
-
if (pruned > 0) saveState(state);
|
|
337
|
+
if (pruned > 0) saveState(state, identity);
|
|
325
338
|
|
|
326
339
|
if (changedBuckets.length === 0 && changedSessions.length === 0) {
|
|
327
340
|
if (!quiet) console.log(dim('无新增数据。'));
|
|
@@ -422,7 +435,7 @@ export async function runSync({
|
|
|
422
435
|
batchStateChanged = true;
|
|
423
436
|
}
|
|
424
437
|
}
|
|
425
|
-
if (batchStateChanged) saveState(state);
|
|
438
|
+
if (batchStateChanged) saveState(state, identity);
|
|
426
439
|
}
|
|
427
440
|
|
|
428
441
|
if (totalBatches > 1 || allBucketsToSend.length > 0) {
|
package/src/tools.js
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
grokSessionsDir,
|
|
13
13
|
} from './extra-roots.js';
|
|
14
14
|
import { findClineDataDirs } from './cline-roots.js';
|
|
15
|
+
import { findCodeartsAgentDbs, resolveCodeartsAgentRoots } from './codearts-roots.js';
|
|
15
16
|
import { findColaDataDirs, getColaSessionsDir } from './cola-roots.js';
|
|
16
17
|
import { findCraftDataDirs } from './craft-roots.js';
|
|
17
18
|
import { findHermesDataDirs, getHermesHome } from './hermes-roots.js';
|
|
@@ -145,6 +146,31 @@ export function getDshSessionsDir() {
|
|
|
145
146
|
return join(getDshHome(), 'sessions');
|
|
146
147
|
}
|
|
147
148
|
|
|
149
|
+
export function getDroidSessionsDir() {
|
|
150
|
+
const testDir = process.env.VIBE_USAGE_DROID_SESSIONS?.trim();
|
|
151
|
+
if (testDir) return testDir;
|
|
152
|
+
return join(homedir(), '.factory', 'sessions');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Factory settings hold customModels[].id → API model. Fixture sessions must
|
|
156
|
+
// not read the real ~/.factory/settings.json (API keys, and it would leak
|
|
157
|
+
// machine catalog into tests).
|
|
158
|
+
export function getDroidSettingsPaths() {
|
|
159
|
+
const override = process.env.VIBE_USAGE_DROID_SETTINGS?.trim();
|
|
160
|
+
if (override) return [override];
|
|
161
|
+
if (process.env.VIBE_USAGE_DROID_SESSIONS?.trim()) return [];
|
|
162
|
+
const home = join(homedir(), '.factory');
|
|
163
|
+
return [
|
|
164
|
+
join(home, 'settings.json'),
|
|
165
|
+
join(home, 'settings.local.json'),
|
|
166
|
+
join(home, 'config.json'),
|
|
167
|
+
];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function findDroidDataDirs() {
|
|
171
|
+
return [getDroidSessionsDir()].filter(existsSync);
|
|
172
|
+
}
|
|
173
|
+
|
|
148
174
|
// Detect DeepSeek Harness when its sessions tree exists (or the test override).
|
|
149
175
|
export function findDshDataDirs() {
|
|
150
176
|
return [getDshSessionsDir()].filter(existsSync);
|
|
@@ -279,6 +305,12 @@ export const TOOLS = [
|
|
|
279
305
|
dataDir: join(homedir(), '.claude', 'projects'),
|
|
280
306
|
detectDataDirs: ({ extraRoots } = {}) => findClaudeCodeDataDirs(extraRootList(extraRoots?.['claude-code'])),
|
|
281
307
|
},
|
|
308
|
+
{
|
|
309
|
+
name: 'CodeArts Agent',
|
|
310
|
+
id: 'codearts-agent',
|
|
311
|
+
dataDir: resolveCodeartsAgentRoots()[0],
|
|
312
|
+
detectDataDirs: () => findCodeartsAgentDbs(),
|
|
313
|
+
},
|
|
282
314
|
{
|
|
283
315
|
name: 'Codex CLI',
|
|
284
316
|
id: 'codex',
|
|
@@ -402,6 +434,7 @@ export const TOOLS = [
|
|
|
402
434
|
name: 'Droid',
|
|
403
435
|
id: 'droid',
|
|
404
436
|
dataDir: join(homedir(), '.factory', 'sessions'),
|
|
437
|
+
detectDataDirs: findDroidDataDirs,
|
|
405
438
|
},
|
|
406
439
|
{
|
|
407
440
|
name: 'DeepSeek Harness',
|