@vibe-cafe/vibe-usage 0.10.29 → 0.10.31
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 +14 -5
- package/package.json +1 -1
- package/src/claude-roots.js +12 -3
- package/src/cline-roots.js +46 -17
- package/src/daemon-service.js +3 -0
- package/src/extra-roots.js +23 -1
- package/src/index.js +2 -2
- package/src/opencode-roots.js +46 -0
- package/src/parsers/claude-code.js +2 -1
- package/src/parsers/cline-sdk.js +106 -0
- package/src/parsers/cline.js +9 -4
- package/src/parsers/codex-cache.js +1 -1
- package/src/parsers/codex-segments.js +108 -0
- package/src/parsers/codex.js +73 -16
- package/src/parsers/opencode.js +67 -134
- package/src/summary.js +19 -1
- package/src/tools.js +5 -1
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@ npx @vibe-cafe/vibe-usage init # Re-run setup via browser login (also ho
|
|
|
38
38
|
npx @vibe-cafe/vibe-usage init --manual-key <vbu_...> # Skip browser, use pre-issued key (CI/headless)
|
|
39
39
|
npx @vibe-cafe/vibe-usage sync # Manual sync
|
|
40
40
|
npx @vibe-cafe/vibe-usage sync --extra-codex-home /path/to/.codex # Add another Codex Home for this run only
|
|
41
|
-
npx @vibe-cafe/vibe-usage summary # Print last 7 days as markdown (cost / tokens / by model / by project)
|
|
41
|
+
npx @vibe-cafe/vibe-usage summary # Print last 7 days as markdown (cost / tokens / by tool / by model / by project)
|
|
42
42
|
npx @vibe-cafe/vibe-usage summary --days N # Same, over the last N days (1-90)
|
|
43
43
|
npx @vibe-cafe/vibe-usage daemon # Continuous sync (every 30m, foreground)
|
|
44
44
|
npx @vibe-cafe/vibe-usage daemon install # Install background service (systemd/launchd/Task Scheduler)
|
|
@@ -63,7 +63,7 @@ npx @vibe-cafe/vibe-usage help --all # Full help (plain `help` shows the short
|
|
|
63
63
|
| 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. |
|
|
64
64
|
| 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. |
|
|
65
65
|
| 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. |
|
|
66
|
-
| 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,
|
|
66
|
+
| 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 |
|
|
67
67
|
| 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. |
|
|
68
68
|
| 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 |
|
|
69
69
|
| GitHub Copilot CLI | `~/.copilot/session-state/*/events.jsonl` |
|
|
@@ -71,7 +71,7 @@ npx @vibe-cafe/vibe-usage help --all # Full help (plain `help` shows the short
|
|
|
71
71
|
| Cursor | `state.vscdb` (SQLite, reads `cursorAuth/accessToken`, fetches CSV from `cursor.com`); cloud data is stamped with a fixed `cursor-cloud` hostname so multi-machine setups don't double-count |
|
|
72
72
|
| DimAgent | `$DIMCODE_HOME/dimcode.sqlite` (default `~/.dimcode/v2/dimcode.sqlite`); exact usage from `usage_ledger`, with forked ledger/history copies deduplicated |
|
|
73
73
|
| Gemini CLI | `~/.gemini/tmp/<project_hash>/chats/session-*.jsonl` (current line-delimited format) and legacy `session-*.json`; recurses into nested subagent sessions |
|
|
74
|
-
| OpenCode | `~/.local/share/opencode/opencode.db` (SQLite, `
|
|
74
|
+
| OpenCode | `~/.local/share/opencode/opencode.db` (SQLite), with `storage/message/` as the legacy alternative; supports explicitly added data roots |
|
|
75
75
|
| OpenClaw | `~/.openclaw/agents/`, `~/.openclaw-<profile>/agents/` (profile deployments); cache-creation/cache-write tokens are included in input usage |
|
|
76
76
|
| Oh My Pi | `~/.omp/agent/sessions/`, `~/.omp/profiles/*/agent/sessions/`, and `$XDG_DATA_HOME/omp/{sessions,profiles/*/sessions}`; recognizes OMP's `$PI_CODING_AGENT_DIR`, current v3 title slots and path/hashed session directories, deduplicates copied records, includes cache writes in input, and splits reasoning from OMP's inclusive output count |
|
|
77
77
|
| pi | `~/.pi/agent/sessions/` or `$PI_CODING_AGENT_DIR/sessions/`, plus the session directory Pi itself was pointed at via `PI_CODING_AGENT_SESSION_DIR` or `sessionDir` in `~/.pi/agent/settings.json`, plus explicitly added `pi-coding-agent` roots for stores only reachable through `pi --session <file>` (fixture/relocation override: `VIBE_USAGE_PI_SESSION_DIRS`). Cache writes are included in input usage; reasoning is read from Pi's `usage.reasoning` (legacy `usage.reasoningTokens` still accepted) and split out of the inclusive output total |
|
|
@@ -84,7 +84,7 @@ npx @vibe-cafe/vibe-usage help --all # Full help (plain `help` shows the short
|
|
|
84
84
|
| DeepSeek Harness | `$DSH_HOME/sessions/` (default `~/.dsh`, fixture/relocation override: `VIBE_USAGE_DSH_SESSIONS`). Reads V0–V3 logs, including `session.v3.jsonl.zstd` from DSH `0.1.5-alpha.2`, with multi-frame Zstandard support (Node ≥ 22.15 built-in, `zstd` CLI fallback) and plain JSONL support. Each session uses its highest `session[.vN].jsonl[.zstd]` generation once, so frozen pre-migration logs are not double-counted. Usage comes from `assistant/message`: cache writes join uncached input, cache reads remain separate, and reasoning is split out of inclusive output. Fork history uses V0/V1 `seedLength` or V2/V3's last `session/end-seed` tagged `inherited: true`, and is skipped only when the parent copy confirms it; missing parents retain the sole local history. Unknown versions warn and protect sync state. |
|
|
85
85
|
| Hermes (CLI / Desktop) | `<home>/state.db` + `<home>/profiles/<name>/state.db` (SQLite, multi-profile). Home: `$HERMES_HOME`, otherwise `~/.hermes` on macOS/Linux or `%LOCALAPPDATA%\hermes` on Windows (falls back to an existing `~/.hermes` only when the Windows native root is absent). Cache writes join input; reasoning is separated from inclusive output. Usage is currently a cumulative session total attributed to session start: a session spanning several days does **not** yet provide an accurate daily breakdown. |
|
|
86
86
|
| Kiro | Kiro CLI native event streams `~/.kiro/sessions/cli/*.jsonl` (estimated tokens from message text: input = prompt + tool results, output = reply + tool calls, reasoning = thinking, cacheRead = re-sent context; thinking-block signatures excluded). Falls back to `~/Library/Application Support/kiro-cli/data.sqlite3` / `~/.local/share/kiro-cli/data.sqlite3` + optional `~/.kiro_sessions/*.json` archives, then IDE `q-client.log` whole-credit deltas as `kiro-credits` (floored cumulative diff — the server stores token counts as bigint); legacy IDE `dev_data/devdata.sqlite` token telemetry is opt-in with `VIBE_USAGE_KIRO_LEGACY_TOKENS=1` |
|
|
87
|
-
| Cline |
|
|
87
|
+
| Cline | Current CLI/SDK `~/.cline/data/sessions/*/*.messages.json` plus legacy `~/.cline/{,data/}state/taskHistory.json` and editor extension stores. Honors `CLINE_DIR`, `CLINE_DATA_DIR`, and `CLINE_SESSION_DATA_DIR`; copied history is deduplicated |
|
|
88
88
|
| Roo Code | `<host>/User/globalStorage/rooveterinaryinc.roo-cline/{tasks/_index.json,tasks/<id>/{history_item,ui_messages}.json}` (walks all VSCode-fork hosts) |
|
|
89
89
|
| Trae CLI | macOS: `~/Library/Caches/trae-cli/sessions/`; Windows: `%LOCALAPPDATA%/trae-cli/cache/sessions/`; Linux: `~/.cache/trae-cli/sessions/` (CLI telemetry only; Trae IDE/Trae Work chats are not supported). Token usage is summed per unique LLM call (`model.stream.eino`, plus `model.generate` failovers); nested duplicate spans that share a session `traceID` are not max-merged. `traces.jsonl` / `events.jsonl` are streamed line-by-line so a multi-hundred-MB events file cannot hit Node's string-length limit. |
|
|
90
90
|
| Antigravity | Scans App 2.0 `~/.gemini/antigravity/conversations/`, `agy` CLI `~/.gemini/antigravity-cli/conversations/`, and standalone IDE `~/.gemini/antigravity-ide/conversations/`. `.db` stores, including the same paths below explicitly added alternate Homes, are parsed offline (tokens, model, project, sessions). When Gemini blobs omit `chatStartMetadata.createdAt` or `modelDisplayName`, timestamps fall back to `steps.metadata` and model names to `responseModel`. `.pb` history in the default stores requires the corresponding App/IDE language server to be running; when several servers are open, the parser tries the others for unreadable conversations. Unavailable legacy history produces a warning and preserves prior sync state. |
|
|
@@ -100,7 +100,7 @@ npx @vibe-cafe/vibe-usage help --all # Full help (plain `help` shows the short
|
|
|
100
100
|
- Extracts session metadata where the source safely exposes user/assistant timing: active time (AI generation time, excluding queue/TTFT wait), total duration, and message counts. Alma intentionally emits buckets only; Cindy's daily-ledger augmentation adds no timing data to the native Codex/pi sessions because doing so would require reading Cindy chat records.
|
|
101
101
|
- Uploads buckets + sessions to your vibecafe.ai dashboard (always gzip-compressed, ~94% smaller)
|
|
102
102
|
- Incremental upload: every parser emits a complete local snapshot, then only buckets/sessions that are new or changed since the last successful upload are sent — a quiet machine uploads nothing. Upload state remains in `~/.vibe-usage/state.json`; failed or still-indexing parsers retain their prior state, while deleted local logs are pruned. Deleting the state file triggers a one-time full re-upload, and `reset` clears it automatically after deleting cloud data
|
|
103
|
-
- Incremental Codex parsing: a versioned, disposable cache under `~/.vibe-usage/cache/codex/` stores per-rollout aggregate results and parser continuation state. Unchanged rollouts require no raw-log reads; an ordinary append reads only the new tail; forks, sub-agents, replacements, truncations, and failed safety checks fall back to the full correctness path. A bounded rolling audit occasionally re-reads one historical file. Very large first-time indexes checkpoint before the Mac app timeout and resume on the next sync instead of restarting
|
|
103
|
+
- Incremental Codex parsing: a versioned, disposable cache under `~/.vibe-usage/cache/codex/` stores per-rollout aggregate results and parser continuation state. Unchanged rollouts require no raw-log reads; an ordinary append reads only the new tail; forks, sub-agents, replacements, truncations, and failed safety checks fall back to the full correctness path. Multi-file sessions have a combined cache that rebuilds when any segment changes. A bounded rolling audit occasionally re-reads one historical file or continuation group. Very large first-time indexes checkpoint before the Mac app timeout and resume on the next sync instead of restarting
|
|
104
104
|
- The Codex parser cache contains derived aggregates and replay metadata, not raw prompt or response text. It is independent of upload state and can be deleted safely (the next sync rebuilds it). `reset` intentionally keeps it so the required full re-upload does not also require a full disk rescan. Set `VIBE_USAGE_CODEX_CACHE=0` to disable the optimization for diagnosis
|
|
105
105
|
- SQLite-backed tools are read via Node's built-in `node:sqlite` on Node ≥ 22.5 — no `sqlite3` binary needed (works on Windows out of the box); on older Node the CLI falls back to the system `sqlite3` executable
|
|
106
106
|
- Continuous syncing is on by default: the first run installs a background service (see [Background sync](#background-sync)); the [Vibe Usage Mac app](https://github.com/vibe-cafe/vibe-usage-app) is the menu-bar alternative
|
|
@@ -203,6 +203,13 @@ Add isolated runtime data without editing JSON by hand:
|
|
|
203
203
|
# <workspace>/<task>/codex-home directories are at most three levels below it.
|
|
204
204
|
npx @vibe-cafe/vibe-usage config add-root codex /path/to/multica-container
|
|
205
205
|
|
|
206
|
+
# Claude Code expects a .claude root containing projects/ or transcripts/.
|
|
207
|
+
# Use the source id claude-code (not claude).
|
|
208
|
+
npx @vibe-cafe/vibe-usage config add-root claude-code /mnt/c/Users/you/.claude
|
|
209
|
+
|
|
210
|
+
# OpenCode accepts a data root containing opencode.db or storage/message/.
|
|
211
|
+
npx @vibe-cafe/vibe-usage config add-root opencode /path/to/other/opencode
|
|
212
|
+
|
|
206
213
|
# Grok expects a Grok Home containing sessions/.
|
|
207
214
|
npx @vibe-cafe/vibe-usage config add-root grok /path/to/grok-home
|
|
208
215
|
|
|
@@ -221,6 +228,8 @@ npx @vibe-cafe/vibe-usage config remove-root grok /path/to/grok-home
|
|
|
221
228
|
|
|
222
229
|
Default roots are always scanned and existing `codexExtraHome` configurations remain valid. Additional roots are only scanned after they are explicitly added. If a configured root later becomes unavailable, that tool is skipped for the current sync so its incremental upload state is not pruned.
|
|
223
230
|
|
|
231
|
+
For OpenCode, each root uses its SQLite database when present; only roots without a database use legacy JSON. Copied records across roots are counted once using session/message ids, keeping the most complete copy. A broken database is reported and preserves sync state instead of silently substituting potentially stale JSON. Existing model names take precedence; nested model fields are only a fallback when the old field is absent. Claude Code retains its existing session and request deduplication rules.
|
|
232
|
+
|
|
224
233
|
## Background sync
|
|
225
234
|
|
|
226
235
|
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.
|
package/package.json
CHANGED
package/src/claude-roots.js
CHANGED
|
@@ -2,6 +2,8 @@ import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
|
2
2
|
import { delimiter, join } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
|
|
5
|
+
import { validateExtraRoot } from './extra-roots.js';
|
|
6
|
+
|
|
5
7
|
const MAX_DESKTOP_DISCOVERY_DEPTH = 8;
|
|
6
8
|
const DESKTOP_NON_SESSION_DIRS = new Set(['rpm', 'skills']);
|
|
7
9
|
|
|
@@ -106,7 +108,7 @@ export function findClaudeDesktopRoots(
|
|
|
106
108
|
* VIBE_USAGE_CLAUDE_DIRS is a test/diagnostic override. It replaces all normal
|
|
107
109
|
* and Desktop discovery with a path.delimiter-separated root list.
|
|
108
110
|
*/
|
|
109
|
-
export function getClaudeRoots({ onWarning = () => {} } = {}) {
|
|
111
|
+
export function getClaudeRoots({ onWarning = () => {}, extraRoots = [] } = {}) {
|
|
110
112
|
const override = process.env.VIBE_USAGE_CLAUDE_DIRS?.trim();
|
|
111
113
|
const roots = override
|
|
112
114
|
? override.split(delimiter).map(expandHome).filter(Boolean)
|
|
@@ -133,6 +135,13 @@ export function getClaudeRoots({ onWarning = () => {} } = {}) {
|
|
|
133
135
|
}
|
|
134
136
|
}
|
|
135
137
|
|
|
138
|
+
// Append explicit extra roots (e.g. another OS's .claude directory).
|
|
139
|
+
for (const root of extraRoots) {
|
|
140
|
+
const result = validateExtraRoot('claude-code', root);
|
|
141
|
+
if (!result.ok) onWarning(`Claude Code: 额外目录不可用 ${root}: ${result.reason}`);
|
|
142
|
+
if (!roots.includes(result.path)) roots.push(result.path);
|
|
143
|
+
}
|
|
144
|
+
|
|
136
145
|
const seen = new Set();
|
|
137
146
|
const unique = [];
|
|
138
147
|
for (const root of roots) {
|
|
@@ -149,9 +158,9 @@ export function getClaudeRoots({ onWarning = () => {} } = {}) {
|
|
|
149
158
|
return unique;
|
|
150
159
|
}
|
|
151
160
|
|
|
152
|
-
export function findClaudeCodeDataDirs() {
|
|
161
|
+
export function findClaudeCodeDataDirs(extraRoots = []) {
|
|
153
162
|
const dirs = [];
|
|
154
|
-
for (const root of getClaudeRoots()) {
|
|
163
|
+
for (const root of getClaudeRoots({ extraRoots })) {
|
|
155
164
|
for (const name of ['projects', 'transcripts']) {
|
|
156
165
|
const candidate = join(root, name);
|
|
157
166
|
try {
|
package/src/cline-roots.js
CHANGED
|
@@ -1,18 +1,10 @@
|
|
|
1
|
-
import { statSync } from 'node:fs';
|
|
1
|
+
import { realpathSync, statSync } from 'node:fs';
|
|
2
2
|
import { delimiter, join } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
|
|
5
5
|
const EXTENSION_ID = 'saoudrizwan.claude-dev';
|
|
6
6
|
const HOSTS = ['Code', 'Cursor', 'Windsurf', 'VSCodium', 'Code - Insiders', 'Trae', 'Trae CN'];
|
|
7
7
|
|
|
8
|
-
function hasTaskHistory(root) {
|
|
9
|
-
try {
|
|
10
|
-
return statSync(join(root, 'state', 'taskHistory.json')).isFile();
|
|
11
|
-
} catch {
|
|
12
|
-
return false;
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
|
|
16
8
|
function hostRoots() {
|
|
17
9
|
const out = [];
|
|
18
10
|
if (process.platform === 'darwin') {
|
|
@@ -28,13 +20,50 @@ function hostRoots() {
|
|
|
28
20
|
return out;
|
|
29
21
|
}
|
|
30
22
|
|
|
31
|
-
|
|
23
|
+
/** Both legacy stores and the shared Cline 3.x CLI/extension SDK store. */
|
|
24
|
+
export function findClineStores({ onWarning = () => {} } = {}) {
|
|
25
|
+
function isPath(path, directory = false) {
|
|
26
|
+
try {
|
|
27
|
+
const stat = statSync(path);
|
|
28
|
+
return directory ? stat.isDirectory() : stat.isFile();
|
|
29
|
+
} catch (err) {
|
|
30
|
+
if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') {
|
|
31
|
+
onWarning(`cline: 无法读取数据目录 ${path}: ${err.message}`);
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function unique(paths) {
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
return paths.filter(path => {
|
|
39
|
+
let key;
|
|
40
|
+
try { key = realpathSync(path); } catch { key = path; }
|
|
41
|
+
if (seen.has(key)) return false;
|
|
42
|
+
seen.add(key);
|
|
43
|
+
return true;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
32
46
|
const override = process.env.VIBE_USAGE_CLINE_DIRS?.trim();
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
47
|
+
const home = join(homedir(), '.cline');
|
|
48
|
+
const configuredHome = process.env.CLINE_DIR?.trim() || home;
|
|
49
|
+
const dataDir = process.env.CLINE_DATA_DIR?.trim() || join(configuredHome, 'data');
|
|
50
|
+
const roots = override
|
|
51
|
+
? override.split(delimiter).map(value => value.trim()).filter(Boolean)
|
|
52
|
+
: [home, configuredHome, dataDir,
|
|
53
|
+
...hostRoots().map(root => join(root, 'User', 'globalStorage', EXTENSION_ID))];
|
|
54
|
+
// Accept either a Cline home or its data directory. Keep the old standalone
|
|
55
|
+
// and editor stores so upgrading the runtime does not discard old history.
|
|
56
|
+
const dataRoots = unique(roots.flatMap(root => [root, join(root, 'data')]));
|
|
57
|
+
const legacyRoots = dataRoots.filter(root => isPath(join(root, 'state', 'taskHistory.json')));
|
|
58
|
+
const sessionDirs = override
|
|
59
|
+
? dataRoots.map(root => join(root, 'sessions'))
|
|
60
|
+
: [...dataRoots.map(root => join(root, 'sessions')),
|
|
61
|
+
process.env.CLINE_SESSION_DATA_DIR?.trim()].filter(Boolean);
|
|
62
|
+
const sdkSessionDirs = unique(sessionDirs).filter(dir => isPath(dir, true));
|
|
63
|
+
return { legacyRoots: unique(legacyRoots), sdkSessionDirs };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function findClineDataDirs() {
|
|
67
|
+
const { legacyRoots, sdkSessionDirs } = findClineStores();
|
|
68
|
+
return [...legacyRoots, ...sdkSessionDirs];
|
|
40
69
|
}
|
package/src/daemon-service.js
CHANGED
|
@@ -115,6 +115,9 @@ function escapeXml(value) {
|
|
|
115
115
|
// launchd/systemd unit that inherits nothing, so anything the parsers read for
|
|
116
116
|
// discovery has to be captured into the unit at install time.
|
|
117
117
|
const PRESERVED_SERVICE_ENV = [
|
|
118
|
+
'CLINE_DIR',
|
|
119
|
+
'CLINE_DATA_DIR',
|
|
120
|
+
'CLINE_SESSION_DATA_DIR',
|
|
118
121
|
'COLA_DATA_DIR',
|
|
119
122
|
'HERMES_HOME',
|
|
120
123
|
'MCODE_HOME',
|
package/src/extra-roots.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { accessSync, closeSync, constants, openSync, readSync, readdirSync, statSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { basename, join, resolve } from 'node:path';
|
|
4
|
+
import { openCodeStore } from './opencode-roots.js';
|
|
4
5
|
import { codexSessionDirs } from './codex-roots.js';
|
|
5
6
|
|
|
6
|
-
export const EXTRA_ROOT_SOURCES = ['antigravity', 'codex', 'grok', 'pi-coding-agent'];
|
|
7
|
+
export const EXTRA_ROOT_SOURCES = ['antigravity', 'claude-code', 'codex', 'grok', 'opencode', 'pi-coding-agent'];
|
|
7
8
|
|
|
8
9
|
// Probing a candidate Pi store has three outcomes, never two: a confirmed
|
|
9
10
|
// session, a directory proven to hold none, and one that could not be read.
|
|
@@ -278,6 +279,11 @@ function probePiSessions(dir, depth = 2) {
|
|
|
278
279
|
return unreadable ? PI_SESSIONS_UNREADABLE : PI_SESSIONS_ABSENT;
|
|
279
280
|
}
|
|
280
281
|
|
|
282
|
+
export function claudeProjectsDir(value) {
|
|
283
|
+
const root = normalizeExtraRoot(value);
|
|
284
|
+
return [join(root, 'projects'), join(root, 'transcripts')].filter(isReadableDirectory);
|
|
285
|
+
}
|
|
286
|
+
|
|
281
287
|
export function validateExtraRoot(source, value) {
|
|
282
288
|
if (!EXTRA_ROOT_SOURCES.includes(source)) {
|
|
283
289
|
return { ok: false, path: value, reason: `不支持的工具: ${source}` };
|
|
@@ -291,6 +297,22 @@ export function validateExtraRoot(source, value) {
|
|
|
291
297
|
reason: '需要是 Codex Home,或包含 */*/codex-home 的 Multica 容器',
|
|
292
298
|
};
|
|
293
299
|
}
|
|
300
|
+
if (source === 'claude-code') {
|
|
301
|
+
const dirs = claudeProjectsDir(path);
|
|
302
|
+
return {
|
|
303
|
+
ok: dirs.length > 0,
|
|
304
|
+
path,
|
|
305
|
+
reason: '需要包含 projects/ 或 transcripts/',
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
if (source === 'opencode') {
|
|
309
|
+
try {
|
|
310
|
+
return { ok: openCodeStore(path) !== null, path,
|
|
311
|
+
reason: '需要包含可读的 opencode.db 或 storage/message/' };
|
|
312
|
+
} catch (err) {
|
|
313
|
+
return { ok: false, path, reason: `无法读取 OpenCode 目录: ${err.message}` };
|
|
314
|
+
}
|
|
315
|
+
}
|
|
294
316
|
if (source === 'pi-coding-agent') {
|
|
295
317
|
// piSessionsDir only returns a directory it has already confirmed by
|
|
296
318
|
// content, so there is nothing left to re-check here.
|
package/src/index.js
CHANGED
|
@@ -252,7 +252,7 @@ const FULL_HELP = `
|
|
|
252
252
|
${BARE} init --manual-key <vbu_...> Skip browser, use a pre-issued key (CI/headless)
|
|
253
253
|
${BARE} sync Manually sync usage data
|
|
254
254
|
${BARE} sync --extra-codex-home <path> Use another Codex Home for this run
|
|
255
|
-
${BARE} summary Print last 7 days as markdown (cost/tokens/model/project)
|
|
255
|
+
${BARE} summary Print last 7 days as markdown (cost/tokens/tool/model/project)
|
|
256
256
|
${BARE} summary --days N Same, but over the last N days (1-90)
|
|
257
257
|
${BARE} daemon Continuous sync (every 30m, foreground)
|
|
258
258
|
${BARE} daemon install Install background service (systemd/launchd/Task Scheduler)
|
|
@@ -269,7 +269,7 @@ const FULL_HELP = `
|
|
|
269
269
|
${BARE} config get <key> Get a config value
|
|
270
270
|
${BARE} config set <key> <value> Set a config value
|
|
271
271
|
${BARE} config set codexExtraHome <path> Persist another Codex Home
|
|
272
|
-
${BARE} config add-root <tool> <path> Add a Codex, Grok, Antigravity, or Pi data root
|
|
272
|
+
${BARE} config add-root <tool> <path> Add a Claude Code, Codex, Grok, OpenCode, Antigravity, or Pi data root
|
|
273
273
|
${BARE} config remove-root <tool> <path> Remove an added data root
|
|
274
274
|
${BARE} config roots Show added data roots as JSON
|
|
275
275
|
${BARE} help Show the short help
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { accessSync, constants, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { delimiter, join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
|
|
5
|
+
function readable(path, directory) {
|
|
6
|
+
try {
|
|
7
|
+
const stat = statSync(path);
|
|
8
|
+
if (directory ? !stat.isDirectory() : !stat.isFile()) throw new Error(`格式不正确: ${path}`);
|
|
9
|
+
accessSync(path, constants.R_OK);
|
|
10
|
+
return true;
|
|
11
|
+
} catch (err) {
|
|
12
|
+
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return false;
|
|
13
|
+
throw err;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// SQLite wins within each store; JSON is the legacy alternative, not a second
|
|
18
|
+
// copy of the same migrated history. A failed SQLite read must protect state.
|
|
19
|
+
export function openCodeStore(root) {
|
|
20
|
+
const db = join(root, 'opencode.db');
|
|
21
|
+
if (readable(db, false)) return { kind: 'sqlite', path: db };
|
|
22
|
+
const messages = join(root, 'storage', 'message');
|
|
23
|
+
if (readable(messages, true)) return { kind: 'json', path: messages };
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getOpenCodeStores({ extraRoots = [], onWarning = () => {} } = {}) {
|
|
28
|
+
const override = process.env.VIBE_USAGE_OPENCODE_DIRS?.trim();
|
|
29
|
+
const defaults = override ? override.split(delimiter).map(p => p.trim()).filter(Boolean)
|
|
30
|
+
: [join(homedir(), '.local', 'share', 'opencode')];
|
|
31
|
+
const seen = new Set(), stores = [];
|
|
32
|
+
for (const root of [...defaults, ...extraRoots]) {
|
|
33
|
+
try {
|
|
34
|
+
const store = openCodeStore(root);
|
|
35
|
+
if (!store) {
|
|
36
|
+
if (extraRoots.includes(root)) onWarning(`OpenCode: 额外目录缺少 opencode.db 或 storage/message/: ${root}`);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const canonical = realpathSync(store.path);
|
|
40
|
+
if (seen.has(canonical)) continue;
|
|
41
|
+
seen.add(canonical);
|
|
42
|
+
stores.push({ ...store, path: canonical });
|
|
43
|
+
} catch (err) { onWarning(`OpenCode: 无法读取数据目录 ${root}: ${err.message}`); }
|
|
44
|
+
}
|
|
45
|
+
return stores;
|
|
46
|
+
}
|
|
@@ -331,7 +331,7 @@ function* iterateUsageEntries(ctx) {
|
|
|
331
331
|
for (const entry of ctx.entriesByKey.values()) yield entry;
|
|
332
332
|
}
|
|
333
333
|
|
|
334
|
-
export async function parse() {
|
|
334
|
+
export async function parse({ extraRoots = [] } = {}) {
|
|
335
335
|
const ctx = {
|
|
336
336
|
entriesByKey: new Map(),
|
|
337
337
|
anonymousEntries: [],
|
|
@@ -341,6 +341,7 @@ export async function parse() {
|
|
|
341
341
|
};
|
|
342
342
|
const roots = getClaudeRoots({
|
|
343
343
|
onWarning: (message) => addWarning(ctx, message),
|
|
344
|
+
extraRoots,
|
|
344
345
|
});
|
|
345
346
|
const projectGroups = collectCandidates(roots, 'projects', ctx);
|
|
346
347
|
const projectSessionIds = new Set();
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { projectFromCwd, toCount } from './fs-utils.js';
|
|
4
|
+
|
|
5
|
+
// Verified with the shipped cline 3.0.61 / @cline/core 0.0.82. SQLite is only
|
|
6
|
+
// the session index: per-call accounting lives in version-1 messages artifacts.
|
|
7
|
+
// Read canonical artifacts, not DB prompt/metadata columns or provider settings.
|
|
8
|
+
export function readClineSdk(sessionDirs, onWarning) {
|
|
9
|
+
const copies = [];
|
|
10
|
+
for (const dir of sessionDirs) {
|
|
11
|
+
let children;
|
|
12
|
+
try { children = readdirSync(dir, { withFileTypes: true }); }
|
|
13
|
+
catch (err) { onWarning(`cline: 无法读取会话目录 ${dir}: ${err.message}`); continue; }
|
|
14
|
+
for (const child of children) {
|
|
15
|
+
if (!child.isDirectory()) continue;
|
|
16
|
+
const sessionDir = join(dir, child.name);
|
|
17
|
+
let files, manifest;
|
|
18
|
+
try {
|
|
19
|
+
files = readdirSync(sessionDir, { withFileTypes: true })
|
|
20
|
+
.filter(file => file.isFile() && file.name.endsWith('.messages.json'));
|
|
21
|
+
if (!files.length) continue;
|
|
22
|
+
manifest = JSON.parse(readFileSync(join(sessionDir, `${child.name}.json`), 'utf8'));
|
|
23
|
+
if (manifest?.version !== 1 || manifest.session_id !== child.name) {
|
|
24
|
+
throw new Error('unsupported or inconsistent Cline session manifest');
|
|
25
|
+
}
|
|
26
|
+
} catch (err) {
|
|
27
|
+
onWarning(`cline: 无法读取会话目录 ${sessionDir}: ${err.message}`);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
for (const file of files) {
|
|
31
|
+
const messagesPath = join(sessionDir, file.name);
|
|
32
|
+
try {
|
|
33
|
+
const payload = JSON.parse(readFileSync(messagesPath, 'utf8'));
|
|
34
|
+
if (payload?.version !== 1 || !Array.isArray(payload.messages)
|
|
35
|
+
|| typeof payload.sessionId !== 'string'
|
|
36
|
+
|| (payload.sessionId !== child.name && payload.origin?.parentThreadId !== child.name)) {
|
|
37
|
+
throw new Error('unsupported or inconsistent Cline session artifact');
|
|
38
|
+
}
|
|
39
|
+
const project = projectFromCwd(manifest.workspace_root || manifest.cwd);
|
|
40
|
+
// Reduce immediately to accounting/timing metadata. No prompt, response,
|
|
41
|
+
// system prompt, tool arguments, credentials, or stored costs survive.
|
|
42
|
+
const messages = payload.messages.flatMap(message => {
|
|
43
|
+
if (!message || !['user', 'assistant'].includes(message.role)) return [];
|
|
44
|
+
if (message.role === 'user' && (payload.agent !== 'lead'
|
|
45
|
+
|| message.metadata?.kind || message.metadata?.userRunSpan === 0
|
|
46
|
+
|| ['system', 'status', 'error', 'tool'].includes(message.metadata?.displayRole)
|
|
47
|
+
|| (Array.isArray(message.content) && message.content.some(block =>
|
|
48
|
+
block?.type === 'tool_result' || block?.type === 'tool-result')))) return [];
|
|
49
|
+
if (typeof message.ts !== 'number' || !Number.isFinite(message.ts)
|
|
50
|
+
|| !Number.isFinite(new Date(message.ts).getTime())) return [];
|
|
51
|
+
// Legacy migration inserts a cumulative metric on an old assistant
|
|
52
|
+
// without a timestamp. Never re-date that summary to the migration.
|
|
53
|
+
const metric = message.role === 'assistant' ? message.metrics : null;
|
|
54
|
+
const input = toCount(metric?.inputTokens);
|
|
55
|
+
const cachedInputTokens = Math.min(input, toCount(metric?.cacheReadTokens));
|
|
56
|
+
return [{
|
|
57
|
+
id: typeof message.id === 'string' ? message.id : null,
|
|
58
|
+
role: message.role,
|
|
59
|
+
timestamp: message.ts,
|
|
60
|
+
model: message.modelInfo?.id || manifest.model || 'cline-unknown',
|
|
61
|
+
inputTokens: input - cachedInputTokens,
|
|
62
|
+
outputTokens: toCount(metric?.outputTokens),
|
|
63
|
+
cachedInputTokens,
|
|
64
|
+
}];
|
|
65
|
+
});
|
|
66
|
+
copies.push({ sessionId: child.name, artifactId: payload.sessionId, project, messages,
|
|
67
|
+
// Restored/forked artifacts retain message ids/timestamps; attribute
|
|
68
|
+
// shared history to the earliest original session deterministically.
|
|
69
|
+
started: Date.parse(manifest.started_at) || 0, messagesPath });
|
|
70
|
+
} catch (err) {
|
|
71
|
+
onWarning(`cline: 无法读取会话 ${messagesPath}: ${err.message}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
copies.sort((a, b) => a.started - b.started
|
|
77
|
+
|| a.sessionId.localeCompare(b.sessionId) || a.messagesPath.localeCompare(b.messagesPath));
|
|
78
|
+
const records = new Map();
|
|
79
|
+
for (const copy of copies) {
|
|
80
|
+
copy.messages.forEach((message, index) => {
|
|
81
|
+
// Anonymous messages can be deduplicated only within copies of this
|
|
82
|
+
// logical session; equal usage in unrelated sessions remains distinct.
|
|
83
|
+
const identity = message.id || `${copy.artifactId}:${index}`;
|
|
84
|
+
const key = JSON.stringify([identity, message.role, message.timestamp]);
|
|
85
|
+
const next = { ...message, sessionId: copy.sessionId, project: copy.project };
|
|
86
|
+
const old = records.get(key);
|
|
87
|
+
const count = value => value.inputTokens + value.outputTokens + value.cachedInputTokens;
|
|
88
|
+
if (!old) records.set(key, next);
|
|
89
|
+
else if (count(next) > count(old)) {
|
|
90
|
+
records.set(key, { ...next, sessionId: old.sessionId, project: old.project });
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
const entries = [], events = [];
|
|
95
|
+
for (const message of records.values()) {
|
|
96
|
+
const base = { source: 'cline', project: message.project, timestamp: new Date(message.timestamp) };
|
|
97
|
+
if (message.role === 'assistant'
|
|
98
|
+
&& message.inputTokens + message.outputTokens + message.cachedInputTokens > 0) {
|
|
99
|
+
entries.push({ ...base, model: message.model, inputTokens: message.inputTokens,
|
|
100
|
+
outputTokens: message.outputTokens, cachedInputTokens: message.cachedInputTokens,
|
|
101
|
+
reasoningOutputTokens: 0 });
|
|
102
|
+
}
|
|
103
|
+
events.push({ ...base, sessionId: message.sessionId, role: message.role });
|
|
104
|
+
}
|
|
105
|
+
return { entries, events };
|
|
106
|
+
}
|
package/src/parsers/cline.js
CHANGED
|
@@ -2,11 +2,13 @@ import { statSync } from 'node:fs';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
4
4
|
import { readJsonSafe, projectFromPath } from './fs-utils.js';
|
|
5
|
-
import {
|
|
5
|
+
import { findClineStores } from '../cline-roots.js';
|
|
6
|
+
import { readClineSdk } from './cline-sdk.js';
|
|
6
7
|
|
|
7
8
|
export async function parse() {
|
|
8
|
-
const
|
|
9
|
-
|
|
9
|
+
const warnings = [];
|
|
10
|
+
const onWarning = message => warnings.push(message);
|
|
11
|
+
const { legacyRoots: extDirs, sdkSessionDirs } = findClineStores({ onWarning });
|
|
10
12
|
|
|
11
13
|
const entries = [];
|
|
12
14
|
const events = [];
|
|
@@ -88,5 +90,8 @@ export async function parse() {
|
|
|
88
90
|
}
|
|
89
91
|
}
|
|
90
92
|
|
|
91
|
-
|
|
93
|
+
const sdk = readClineSdk(sdkSessionDirs, onWarning);
|
|
94
|
+
if (warnings.length) return { buckets: [], sessions: [], skipped: true, warnings };
|
|
95
|
+
return { buckets: aggregateToBuckets([...entries, ...sdk.entries]),
|
|
96
|
+
sessions: extractSessions([...events, ...sdk.events]) };
|
|
92
97
|
}
|
|
@@ -14,7 +14,7 @@ import { join } from 'node:path';
|
|
|
14
14
|
// separate from ~/.vibe-usage/state.json, whose hashes are the authoritative
|
|
15
15
|
// record of successful uploads and must remain backward-compatible.
|
|
16
16
|
export const CODEX_CACHE_SCHEMA_VERSION = 1;
|
|
17
|
-
export const CODEX_PARSER_ALGORITHM_VERSION =
|
|
17
|
+
export const CODEX_PARSER_ALGORITHM_VERSION = 4;
|
|
18
18
|
|
|
19
19
|
function hash(value, length = 24) {
|
|
20
20
|
return createHash('sha256').update(value).digest('hex').slice(0, length);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('base64url');
|
|
4
|
+
function pick(value, keys) {
|
|
5
|
+
if (!value || typeof value !== 'object') return value;
|
|
6
|
+
return Object.fromEntries(keys.filter(key => Object.hasOwn(value, key)).map(key => [key, value[key]]));
|
|
7
|
+
}
|
|
8
|
+
const usageKeys = ['input_tokens', 'output_tokens', 'cached_input_tokens', 'cache_read_input_tokens', 'reasoning_output_tokens', 'total_tokens'];
|
|
9
|
+
|
|
10
|
+
// Retain only fields consumed by the existing token/timing parser. Full JSON is
|
|
11
|
+
// hashed transiently to identify exact copies; chat/tool text is never retained.
|
|
12
|
+
function accountingRecord(obj, context) {
|
|
13
|
+
const next = { type: obj.type, timestamp: obj.timestamp };
|
|
14
|
+
const p = obj.payload;
|
|
15
|
+
if (obj.type === 'session_meta' && p) {
|
|
16
|
+
next.payload = pick(p, ['id', 'timestamp', 'cwd', 'forked_from_id', 'parent_thread_id', 'thread_source']);
|
|
17
|
+
if (p.git) next.payload.git = pick(p.git, ['repository_url']);
|
|
18
|
+
if (typeof p.source === 'string') next.payload.source = p.source;
|
|
19
|
+
else if (p.source && typeof p.source === 'object' && 'subagent' in p.source) {
|
|
20
|
+
next.payload.source = { subagent: { thread_spawn: pick(p.source.subagent?.thread_spawn, ['parent_thread_id']) } };
|
|
21
|
+
}
|
|
22
|
+
} else if (obj.type === 'turn_context') {
|
|
23
|
+
next.payload = pick(p, ['model', 'service_tier']);
|
|
24
|
+
} else if (obj.type === 'event_msg' && p) {
|
|
25
|
+
next.payload = pick(p, ['type', 'started_at', 'model']);
|
|
26
|
+
if (p.type === 'token_count') {
|
|
27
|
+
next._tokenFingerprint = hash(p).slice(0, 16);
|
|
28
|
+
next._segmentContext = { ...context };
|
|
29
|
+
if (p.info) next.payload.info = {
|
|
30
|
+
...pick(p.info, ['model']),
|
|
31
|
+
total_token_usage: pick(p.info.total_token_usage, usageKeys),
|
|
32
|
+
last_token_usage: pick(p.info.last_token_usage, usageKeys),
|
|
33
|
+
};
|
|
34
|
+
} else if (p.type === 'thread_settings_applied') {
|
|
35
|
+
next.payload.thread_settings = pick(p.thread_settings, ['model', 'service_tier']);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return JSON.stringify(next);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Merge exact cross-file copies with multiplicity: occurrence N of a record in
|
|
43
|
+
* one file matches occurrence N in another. Repetitions within a file survive.
|
|
44
|
+
* Preserve each file's order (including rewritten-timestamp fork replay); use
|
|
45
|
+
* timestamps only to order independent segments. Contradictory order fails the
|
|
46
|
+
* source instead of inventing a potentially double-counting sequence.
|
|
47
|
+
*/
|
|
48
|
+
export async function mergeCodexSegments(members, readLines) {
|
|
49
|
+
const nodes = new Map();
|
|
50
|
+
let canonicalKey = null;
|
|
51
|
+
for (const [fileIndex, file] of members.entries()) {
|
|
52
|
+
const occurrences = new Map();
|
|
53
|
+
let previous = null;
|
|
54
|
+
const context = {};
|
|
55
|
+
for await (const line of readLines(file.filePath, file.snapshotSize)) {
|
|
56
|
+
let obj;
|
|
57
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
58
|
+
if (!obj || typeof obj !== 'object') continue;
|
|
59
|
+
const settings = obj.type === 'turn_context' ? obj.payload
|
|
60
|
+
: obj.type === 'event_msg' && obj.payload?.type === 'thread_settings_applied' ? obj.payload.thread_settings : null;
|
|
61
|
+
if (settings?.model) context.model = settings.model;
|
|
62
|
+
if (Object.hasOwn(settings || {}, 'service_tier')) context.serviceTier = settings.service_tier;
|
|
63
|
+
const fingerprint = hash(obj);
|
|
64
|
+
const occurrence = (occurrences.get(fingerprint) || 0) + 1;
|
|
65
|
+
occurrences.set(fingerprint, occurrence);
|
|
66
|
+
const key = `${fingerprint}:${occurrence}`;
|
|
67
|
+
if (fileIndex === 0 && canonicalKey === null && obj.type === 'session_meta') canonicalKey = key;
|
|
68
|
+
if (!nodes.has(key)) nodes.set(key, {
|
|
69
|
+
key, line: accountingRecord(obj, context), time: Date.parse(obj.timestamp),
|
|
70
|
+
ordinal: nodes.size, successors: new Set(), incoming: 0,
|
|
71
|
+
});
|
|
72
|
+
if (previous && previous !== key && !nodes.get(previous).successors.has(key)) {
|
|
73
|
+
nodes.get(previous).successors.add(key);
|
|
74
|
+
nodes.get(key).incoming++;
|
|
75
|
+
}
|
|
76
|
+
previous = key;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const ready = [...nodes.values()].filter(node => !node.incoming);
|
|
80
|
+
const result = [];
|
|
81
|
+
function compare(a, b) {
|
|
82
|
+
if (a.key === canonicalKey || b.key === canonicalKey) return a.key === canonicalKey ? -1 : 1;
|
|
83
|
+
if (Number.isFinite(a.time) && Number.isFinite(b.time) && a.time !== b.time) return a.time - b.time;
|
|
84
|
+
return a.ordinal - b.ordinal;
|
|
85
|
+
}
|
|
86
|
+
while (ready.length) {
|
|
87
|
+
ready.sort(compare);
|
|
88
|
+
const node = ready.shift();
|
|
89
|
+
result.push(node.line);
|
|
90
|
+
for (const key of node.successors) {
|
|
91
|
+
const next = nodes.get(key);
|
|
92
|
+
if (--next.incoming === 0) ready.push(next);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (result.length !== nodes.size) throw new Error('Codex continuation copies have conflicting record order');
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The disposable group cache is invalidated by ANY member changing, appearing,
|
|
100
|
+
// disappearing, or moving. It never shares the selected physical file's cache.
|
|
101
|
+
export function codexSegmentSignature(members) {
|
|
102
|
+
return {
|
|
103
|
+
size: members.reduce((sum, file) => sum + file.snapshotSize, 0),
|
|
104
|
+
mtimeMs: Math.max(...members.map(file => file.signature.mtimeMs)),
|
|
105
|
+
dev: 'codex-segments',
|
|
106
|
+
ino: hash(members.map(file => [file.filePath, file.signature]).sort((a, b) => a[0].localeCompare(b[0]))),
|
|
107
|
+
};
|
|
108
|
+
}
|
package/src/parsers/codex.js
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import { createInterface } from 'node:readline';
|
|
12
12
|
import { createHash } from 'node:crypto';
|
|
13
|
+
import { mergeCodexSegments, codexSegmentSignature } from './codex-segments.js';
|
|
13
14
|
import { aggregateToBuckets } from './aggregate.js';
|
|
14
15
|
import { mergeCindyHarnessUsage, readCindyHarnessUsage } from './cindy-ledger.js';
|
|
15
16
|
import {
|
|
@@ -314,7 +315,7 @@ const OWN_TASK_START_WINDOW_MS = 5_000;
|
|
|
314
315
|
* full prefix. Together they bound matching to source records that existed at
|
|
315
316
|
* spawn without over-skipping child work when the parent later grows.
|
|
316
317
|
*/
|
|
317
|
-
async function indexSessionFile(filePath, snapshotSize) {
|
|
318
|
+
async function indexSessionFile(filePath, snapshotSize, lines = null) {
|
|
318
319
|
let sessionId = null;
|
|
319
320
|
let forkedFromId = null;
|
|
320
321
|
let parentThreadId = null;
|
|
@@ -332,7 +333,7 @@ async function indexSessionFile(filePath, snapshotSize) {
|
|
|
332
333
|
let firstTaskBoundary = null;
|
|
333
334
|
let ownTaskBoundary = null;
|
|
334
335
|
|
|
335
|
-
for await (const line of readLines(filePath, snapshotSize)) {
|
|
336
|
+
for await (const line of (lines ?? readLines(filePath, snapshotSize))) {
|
|
336
337
|
if (!line.trim()) continue;
|
|
337
338
|
try {
|
|
338
339
|
const obj = JSON.parse(line);
|
|
@@ -361,7 +362,7 @@ async function indexSessionFile(filePath, snapshotSize) {
|
|
|
361
362
|
}
|
|
362
363
|
} else if (obj.type === 'event_msg' && obj.payload?.type === 'token_count') {
|
|
363
364
|
rawTokenCount++;
|
|
364
|
-
tokenFingerprints.push(tokenFingerprint(obj.payload));
|
|
365
|
+
tokenFingerprints.push(lines ? obj._tokenFingerprint : tokenFingerprint(obj.payload));
|
|
365
366
|
if (recordTimestamp == null) {
|
|
366
367
|
tokenTimes.push(Number.POSITIVE_INFINITY);
|
|
367
368
|
pendingTokenTimeIndexes.push(tokenTimes.length - 1);
|
|
@@ -612,6 +613,7 @@ function mergeBucketLists(lists) {
|
|
|
612
613
|
async function parseSessionFile(filePath, snapshotSize, fm, boundary, {
|
|
613
614
|
previousTail = null,
|
|
614
615
|
captureTail = false,
|
|
616
|
+
lines = null,
|
|
615
617
|
} = {}) {
|
|
616
618
|
const entries = [];
|
|
617
619
|
const sessionEvents = [];
|
|
@@ -632,7 +634,7 @@ async function parseSessionFile(filePath, snapshotSize, fm, boundary, {
|
|
|
632
634
|
let prevTotal = previousTail?.prevTotal || null;
|
|
633
635
|
let prevCumulativeTotal = previousTail?.prevCumulativeTotal ?? null;
|
|
634
636
|
const start = previousTail?.parsedBytes || 0;
|
|
635
|
-
for await (const line of readLines(filePath, snapshotSize, start)) {
|
|
637
|
+
for await (const line of (lines ?? readLines(filePath, snapshotSize, start))) {
|
|
636
638
|
if (!line.trim()) continue;
|
|
637
639
|
try {
|
|
638
640
|
const obj = JSON.parse(line);
|
|
@@ -750,8 +752,11 @@ async function parseSessionFile(filePath, snapshotSize, fm, boundary, {
|
|
|
750
752
|
const timestamp = obj.timestamp ? new Date(obj.timestamp) : null;
|
|
751
753
|
if (!timestamp || isNaN(timestamp.getTime())) continue;
|
|
752
754
|
|
|
753
|
-
const
|
|
754
|
-
const
|
|
755
|
+
const segmentContext = lines ? obj._segmentContext : null;
|
|
756
|
+
const rawModel = info.model || payload.model || segmentContext?.model || turnContextModel || 'unknown';
|
|
757
|
+
const effectiveTier = Object.hasOwn(segmentContext || {}, 'serviceTier')
|
|
758
|
+
? normalizeCodexServiceTier(segmentContext.serviceTier) : serviceTier;
|
|
759
|
+
const model = decorateCodexModel(rawModel, effectiveTier, timestamp.getTime());
|
|
755
760
|
|
|
756
761
|
// OpenAI API: input_tokens INCLUDES cached, output_tokens INCLUDES reasoning.
|
|
757
762
|
// Normalize to Anthropic-style semantics where each field is non-overlapping.
|
|
@@ -967,13 +972,12 @@ async function parseNativeCodex({ codexExtraHome, extraRoots = [] } = {}) {
|
|
|
967
972
|
cacheStats.filesRead++;
|
|
968
973
|
updateFileCache(file, { header: file.header });
|
|
969
974
|
} catch {
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
}
|
|
976
|
-
continue;
|
|
975
|
+
// An unreadable header may hide another segment of a known session.
|
|
976
|
+
// Never upload the remaining readable segment as its complete total.
|
|
977
|
+
return {
|
|
978
|
+
buckets: [], sessions: [], skipped: true,
|
|
979
|
+
warnings: ['codex: 会话文件读取失败,已保留上次同步数据'],
|
|
980
|
+
};
|
|
977
981
|
}
|
|
978
982
|
}
|
|
979
983
|
if (file.header.sessionId) {
|
|
@@ -1045,8 +1049,55 @@ async function parseNativeCodex({ codexExtraHome, extraRoots = [] } = {}) {
|
|
|
1045
1049
|
}
|
|
1046
1050
|
}
|
|
1047
1051
|
|
|
1048
|
-
//
|
|
1049
|
-
//
|
|
1052
|
+
// Duplicate ids can be disjoint continuation segments, not just archives.
|
|
1053
|
+
// Keep physical caches intact and give each combined session its own cache.
|
|
1054
|
+
const groupedFiles = [];
|
|
1055
|
+
let groupAudited = false;
|
|
1056
|
+
for (const id of duplicateIds) {
|
|
1057
|
+
const members = candidatesById.get(id);
|
|
1058
|
+
if (members.some(file => !fileMeta.has(file.filePath))) {
|
|
1059
|
+
return { buckets: [], sessions: [], skipped: true,
|
|
1060
|
+
warnings: ['codex: 同一会话的部分文件读取失败,已保留上次同步数据'] };
|
|
1061
|
+
}
|
|
1062
|
+
members.sort((a, b) => (fileMeta.get(b.filePath).parsedRecordCount || 0)
|
|
1063
|
+
- (fileMeta.get(a.filePath).parsedRecordCount || 0) || a.filePath.localeCompare(b.filePath));
|
|
1064
|
+
const selected = members[0];
|
|
1065
|
+
const filePath = `${selected.codexHome}/.vibe-usage-session-${createHash('sha256').update(id).digest('hex')}`;
|
|
1066
|
+
const signature = codexSegmentSignature(members);
|
|
1067
|
+
let cache = members.some(file => auditPaths.has(file.filePath)) ? null
|
|
1068
|
+
: loadCodexFileCache(selected.codexHome, filePath, signature);
|
|
1069
|
+
if (cache?.result && !groupAudited && auditPaths.size === 0
|
|
1070
|
+
&& signature.size <= auditMaxBytes()
|
|
1071
|
+
&& (cache.lastAuditedAt || 0) <= Date.now() - auditIntervalMs()) {
|
|
1072
|
+
cache = null;
|
|
1073
|
+
groupAudited = true;
|
|
1074
|
+
cacheStats.audited++;
|
|
1075
|
+
}
|
|
1076
|
+
const group = { ...selected, filePath, signature, snapshotSize: signature.size,
|
|
1077
|
+
cache, members, lines: null, appendTail: null, priorTail: null };
|
|
1078
|
+
try {
|
|
1079
|
+
let meta = cache?.index;
|
|
1080
|
+
if (!meta) {
|
|
1081
|
+
group.lines = await mergeCodexSegments(members, readLines);
|
|
1082
|
+
cacheStats.filesRead += members.length;
|
|
1083
|
+
meta = await indexSessionFile(filePath, null, group.lines);
|
|
1084
|
+
updateFileCache(group, { header: group.header, index: meta });
|
|
1085
|
+
} else cacheStats.indexHits++;
|
|
1086
|
+
fileMeta.set(filePath, meta);
|
|
1087
|
+
needsIndex.add(filePath);
|
|
1088
|
+
for (const member of members) fileMeta.delete(member.filePath);
|
|
1089
|
+
groupedFiles.push(group);
|
|
1090
|
+
} catch (err) {
|
|
1091
|
+
return { buckets: [], sessions: [], skipped: true,
|
|
1092
|
+
warnings: [`codex: 无法合并会话分段,已保留上次同步数据: ${err.message}`] };
|
|
1093
|
+
}
|
|
1094
|
+
if (overBudget()) return { buckets: [], sessions: [], skipped: true,
|
|
1095
|
+
indexing: { phase: 'segments', completed: groupedFiles.length, total: duplicateIds.size }, cache: cacheStats };
|
|
1096
|
+
}
|
|
1097
|
+
files.push(...groupedFiles);
|
|
1098
|
+
|
|
1099
|
+
// One index per logical session, including all continuation segments, feeds
|
|
1100
|
+
// the unchanged fork/subagent replay boundary logic.
|
|
1050
1101
|
const sessionById = new Map();
|
|
1051
1102
|
for (const file of files) {
|
|
1052
1103
|
const meta = fileMeta.get(file.filePath);
|
|
@@ -1077,11 +1128,16 @@ async function parseNativeCodex({ codexExtraHome, extraRoots = [] } = {}) {
|
|
|
1077
1128
|
const previousTail = !needsIndex.has(file.filePath) && !auditPaths.has(file.filePath)
|
|
1078
1129
|
? file.appendTail
|
|
1079
1130
|
: null;
|
|
1131
|
+
if (file.members && !file.lines) {
|
|
1132
|
+
file.lines = await mergeCodexSegments(file.members, readLines);
|
|
1133
|
+
cacheStats.filesRead += file.members.length;
|
|
1134
|
+
}
|
|
1080
1135
|
const parsed = await parseSessionFile(file.filePath, file.snapshotSize, fm, boundary, {
|
|
1081
1136
|
previousTail,
|
|
1082
1137
|
captureTail: !needsIndex.has(file.filePath),
|
|
1138
|
+
lines: file.lines,
|
|
1083
1139
|
});
|
|
1084
|
-
cacheStats.filesRead++;
|
|
1140
|
+
if (!file.members) cacheStats.filesRead++;
|
|
1085
1141
|
if (previousTail) cacheStats.tailHits++;
|
|
1086
1142
|
const { tail, ...summary } = parsed;
|
|
1087
1143
|
if (tail) {
|
|
@@ -1098,6 +1154,7 @@ async function parseNativeCodex({ codexExtraHome, extraRoots = [] } = {}) {
|
|
|
1098
1154
|
file.priorTail = null;
|
|
1099
1155
|
if (auditPaths.has(file.filePath)) cacheStats.audited++;
|
|
1100
1156
|
}
|
|
1157
|
+
file.lines = null; // Never retain the transient combined transcript after parsing.
|
|
1101
1158
|
results.push(result);
|
|
1102
1159
|
|
|
1103
1160
|
if (overBudget() && i < files.length - 1) {
|
package/src/parsers/opencode.js
CHANGED
|
@@ -1,151 +1,84 @@
|
|
|
1
|
-
import { readdirSync, readFileSync
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { join, basename } from 'node:path';
|
|
3
|
-
import { homedir } from 'node:os';
|
|
4
3
|
import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
5
4
|
import { queryDbJson, sqliteUnavailableError, isSqliteUnavailableError } from './sqlite.js';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
process.stderr.write(`warn: opencode sqlite parse failed (${err.message}), trying legacy json...\n`);
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
return parseFromJson();
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function parseFromSqlite() {
|
|
27
|
-
const query = `SELECT
|
|
28
|
-
session_id as sessionID,
|
|
29
|
-
json_extract(data, '$.role') as role,
|
|
30
|
-
json_extract(data, '$.time.created') as created,
|
|
31
|
-
json_extract(data, '$.modelID') as modelID,
|
|
32
|
-
json_extract(data, '$.tokens') as tokens,
|
|
33
|
-
json_extract(data, '$.path.root') as rootPath
|
|
34
|
-
FROM message`;
|
|
35
|
-
|
|
36
|
-
let rows;
|
|
37
|
-
try {
|
|
38
|
-
rows = queryDbJson(DB_PATH, query);
|
|
39
|
-
} catch (err) {
|
|
5
|
+
import { getOpenCodeStores } from '../opencode-roots.js';
|
|
6
|
+
|
|
7
|
+
function readSqlite(path) {
|
|
8
|
+
// Select only accounting/timing metadata, never message text or tool inputs.
|
|
9
|
+
// Keep the existing top-level model/project precedence for old uploads.
|
|
10
|
+
const query = `SELECT id, session_id AS sessionID,
|
|
11
|
+
json_extract(data, '$.role') AS role,
|
|
12
|
+
json_extract(data, '$.time.created') AS created,
|
|
13
|
+
coalesce(json_extract(data, '$.modelID'), json_extract(data, '$.model.modelID')) AS modelID,
|
|
14
|
+
json_extract(data, '$.tokens') AS tokens,
|
|
15
|
+
json_extract(data, '$.path.root') AS rootPath
|
|
16
|
+
FROM message ORDER BY id`;
|
|
17
|
+
try { return queryDbJson(path, query); }
|
|
18
|
+
catch (err) {
|
|
40
19
|
if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('OpenCode');
|
|
41
20
|
throw err;
|
|
42
21
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const entries = [];
|
|
46
|
-
const sessionEvents = [];
|
|
47
|
-
for (const row of rows) {
|
|
48
|
-
const timestamp = new Date(row.created);
|
|
49
|
-
if (isNaN(timestamp.getTime())) continue;
|
|
50
|
-
|
|
51
|
-
const project = row.rootPath ? basename(row.rootPath) : 'unknown';
|
|
52
|
-
const sessionId = row.sessionID || 'unknown';
|
|
53
|
-
|
|
54
|
-
sessionEvents.push({
|
|
55
|
-
sessionId,
|
|
56
|
-
source: 'opencode',
|
|
57
|
-
project,
|
|
58
|
-
timestamp,
|
|
59
|
-
role: row.role === 'user' ? 'user' : 'assistant',
|
|
60
|
-
});
|
|
22
|
+
}
|
|
61
23
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
24
|
+
function readJson(path) {
|
|
25
|
+
const rows = [];
|
|
26
|
+
for (const dir of readdirSync(path, { withFileTypes: true })) {
|
|
27
|
+
if (!dir.isDirectory() || !dir.name.startsWith('ses_')) continue;
|
|
28
|
+
const sessionPath = join(path, dir.name);
|
|
29
|
+
for (const file of readdirSync(sessionPath).sort()) {
|
|
30
|
+
if (!file.endsWith('.json')) continue;
|
|
31
|
+
const data = JSON.parse(readFileSync(join(sessionPath, file), 'utf8'));
|
|
32
|
+
rows.push({ id: data.id || basename(file, '.json'), sessionID: dir.name,
|
|
33
|
+
role: data.role, created: data.time?.created,
|
|
34
|
+
modelID: data.modelID || data.model?.modelID,
|
|
35
|
+
tokens: data.tokens, rootPath: data.path?.root });
|
|
68
36
|
}
|
|
69
|
-
if (!tokens || (!tokens.input && !tokens.output)) continue;
|
|
70
|
-
|
|
71
|
-
entries.push({
|
|
72
|
-
source: 'opencode',
|
|
73
|
-
model: row.modelID || 'unknown',
|
|
74
|
-
project,
|
|
75
|
-
timestamp,
|
|
76
|
-
inputTokens: tokens.input || 0,
|
|
77
|
-
outputTokens: tokens.output || 0,
|
|
78
|
-
cachedInputTokens: tokens.cache?.read || 0,
|
|
79
|
-
reasoningOutputTokens: tokens.reasoning || 0,
|
|
80
|
-
});
|
|
81
37
|
}
|
|
82
|
-
|
|
83
|
-
return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
|
|
38
|
+
return rows;
|
|
84
39
|
}
|
|
85
40
|
|
|
86
|
-
function
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
let sessionDirs;
|
|
92
|
-
try {
|
|
93
|
-
sessionDirs = readdirSync(MESSAGES_DIR, { withFileTypes: true })
|
|
94
|
-
.filter(d => d.isDirectory() && d.name.startsWith('ses_'));
|
|
95
|
-
} catch {
|
|
96
|
-
return { buckets: [], sessions: [] };
|
|
97
|
-
}
|
|
41
|
+
function tokenSize(row) {
|
|
42
|
+
const t = row.tokens;
|
|
43
|
+
return ['input', 'output', 'reasoning'].reduce((n, key) => n + (Number(t?.[key]) || 0), 0)
|
|
44
|
+
+ (Number(t?.cache?.read) || 0);
|
|
45
|
+
}
|
|
98
46
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
47
|
+
export async function parse({ extraRoots = [] } = {}) {
|
|
48
|
+
const warnings = [];
|
|
49
|
+
const stores = getOpenCodeStores({ extraRoots, onWarning: message => warnings.push(message) });
|
|
50
|
+
const records = new Map();
|
|
51
|
+
for (const store of stores) {
|
|
102
52
|
try {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
continue;
|
|
53
|
+
const rows = store.kind === 'sqlite' ? readSqlite(store.path) : readJson(store.path);
|
|
54
|
+
for (const [index, row] of rows.entries()) {
|
|
55
|
+
const timestamp = new Date(row.created);
|
|
56
|
+
if (!Number.isFinite(timestamp.getTime())) continue;
|
|
57
|
+
if (typeof row.tokens === 'string') row.tokens = JSON.parse(row.tokens);
|
|
58
|
+
const sessionId = row.sessionID || 'unknown';
|
|
59
|
+
// Message ids are unique within an OpenCode session. Across stores,
|
|
60
|
+
// keep the most complete copy; never dedup unrelated equal-sized calls.
|
|
61
|
+
// Missing ids cannot prove that two stores hold the same record.
|
|
62
|
+
const key = JSON.stringify([sessionId, row.id || `${store.path}:${index}`]);
|
|
63
|
+
const old = records.get(key);
|
|
64
|
+
if (!old || tokenSize(row) > tokenSize(old)) records.set(key, { ...row, timestamp, sessionId });
|
|
116
65
|
}
|
|
117
|
-
|
|
118
|
-
const timestamp = new Date(data.time?.created);
|
|
119
|
-
if (isNaN(timestamp.getTime())) continue;
|
|
120
|
-
|
|
121
|
-
const rootPath = data.path?.root;
|
|
122
|
-
const project = rootPath ? basename(rootPath) : 'unknown';
|
|
123
|
-
|
|
124
|
-
sessionEvents.push({
|
|
125
|
-
sessionId: sessionDir.name,
|
|
126
|
-
source: 'opencode',
|
|
127
|
-
project,
|
|
128
|
-
timestamp,
|
|
129
|
-
role: data.role === 'user' ? 'user' : 'assistant',
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
if (!data.modelID) continue;
|
|
133
|
-
const tokens = data.tokens;
|
|
134
|
-
if (!tokens) continue;
|
|
135
|
-
if (!tokens.input && !tokens.output) continue;
|
|
136
|
-
|
|
137
|
-
entries.push({
|
|
138
|
-
source: 'opencode',
|
|
139
|
-
model: data.modelID || 'unknown',
|
|
140
|
-
project,
|
|
141
|
-
timestamp,
|
|
142
|
-
inputTokens: tokens.input || 0,
|
|
143
|
-
outputTokens: tokens.output || 0,
|
|
144
|
-
cachedInputTokens: tokens.cache?.read || 0,
|
|
145
|
-
reasoningOutputTokens: tokens.reasoning || 0,
|
|
146
|
-
});
|
|
147
|
-
}
|
|
66
|
+
} catch (err) { warnings.push(`OpenCode: 无法读取 ${store.path}: ${err.message}`); }
|
|
148
67
|
}
|
|
68
|
+
if (warnings.length) return { buckets: [], sessions: [], skipped: true, warnings };
|
|
149
69
|
|
|
150
|
-
|
|
70
|
+
const entries = [], events = [];
|
|
71
|
+
for (const row of records.values()) {
|
|
72
|
+
// Keep the existing project derivation and token semantics. Additional roots
|
|
73
|
+
// must not rename previously uploaded projects/models or alter their counts.
|
|
74
|
+
const project = row.rootPath ? basename(row.rootPath) : 'unknown';
|
|
75
|
+
const base = { source: 'opencode', project, timestamp: row.timestamp };
|
|
76
|
+
events.push({ ...base, sessionId: row.sessionId, role: row.role === 'user' ? 'user' : 'assistant' });
|
|
77
|
+
const tokens = row.tokens;
|
|
78
|
+
if (!row.modelID || !tokens || (!tokens.input && !tokens.output)) continue;
|
|
79
|
+
entries.push({ ...base, model: row.modelID,
|
|
80
|
+
inputTokens: tokens.input || 0, outputTokens: tokens.output || 0,
|
|
81
|
+
cachedInputTokens: tokens.cache?.read || 0, reasoningOutputTokens: tokens.reasoning || 0 });
|
|
82
|
+
}
|
|
83
|
+
return { buckets: aggregateToBuckets(entries), sessions: extractSessions(events) };
|
|
151
84
|
}
|
package/src/summary.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { loadConfig } from './config.js';
|
|
2
2
|
import { getJson } from './api.js';
|
|
3
3
|
import { failure } from './output.js';
|
|
4
|
+
import { TOOLS } from './tools.js';
|
|
5
|
+
|
|
6
|
+
// Server-side source ids are raw slugs ('claude-code'); TOOLS carries the
|
|
7
|
+
// display names. A source the server knows but this CLI doesn't parse falls
|
|
8
|
+
// back to its id rather than disappearing from the table.
|
|
9
|
+
const TOOL_NAMES = new Map(TOOLS.map(t => [t.id, t.name]));
|
|
4
10
|
|
|
5
11
|
export async function runSummary(args = []) {
|
|
6
12
|
const days = parseDays(args);
|
|
@@ -36,7 +42,7 @@ function parseDays(args) {
|
|
|
36
42
|
return v;
|
|
37
43
|
}
|
|
38
44
|
|
|
39
|
-
function render(data, days, apiUrl) {
|
|
45
|
+
export function render(data, days, apiUrl) {
|
|
40
46
|
const buckets = Array.isArray(data?.buckets) ? data.buckets : [];
|
|
41
47
|
const sessions = Array.isArray(data?.sessions) ? data.sessions : [];
|
|
42
48
|
const dashboard = `${apiUrl}/usage`;
|
|
@@ -47,6 +53,7 @@ function render(data, days, apiUrl) {
|
|
|
47
53
|
|
|
48
54
|
let totalCost = 0;
|
|
49
55
|
let totalTokens = 0;
|
|
56
|
+
const bySource = new Map();
|
|
50
57
|
const byModel = new Map();
|
|
51
58
|
const byProject = new Map();
|
|
52
59
|
|
|
@@ -55,6 +62,7 @@ function render(data, days, apiUrl) {
|
|
|
55
62
|
const tokens = Number(b.totalTokens ?? 0);
|
|
56
63
|
totalCost += cost;
|
|
57
64
|
totalTokens += tokens;
|
|
65
|
+
accumulate(bySource, b.source || 'unknown', { cost, tokens });
|
|
58
66
|
accumulate(byModel, b.model, { cost, tokens });
|
|
59
67
|
accumulate(byProject, b.project || 'unknown', { cost, tokens, sessions: 0 });
|
|
60
68
|
}
|
|
@@ -74,6 +82,16 @@ function render(data, days, apiUrl) {
|
|
|
74
82
|
lines.push(`**总览**: $${totalCost.toFixed(2)} · ${formatTokens(totalTokens)} tokens · ${sessionsCount} sessions · ${activeHours.toFixed(1)}h active`);
|
|
75
83
|
lines.push('');
|
|
76
84
|
|
|
85
|
+
lines.push('## 按工具');
|
|
86
|
+
lines.push('');
|
|
87
|
+
lines.push('| 工具 | 费用 | Tokens | 占比 |');
|
|
88
|
+
lines.push('|---|---:|---:|---:|');
|
|
89
|
+
for (const [source, { cost, tokens }] of topN(bySource, 'cost', 8)) {
|
|
90
|
+
const pct = totalCost > 0 ? ((cost / totalCost) * 100).toFixed(0) : '0';
|
|
91
|
+
lines.push(`| ${TOOL_NAMES.get(source) || source} | $${cost.toFixed(2)} | ${formatTokens(tokens)} | ${pct}% |`);
|
|
92
|
+
}
|
|
93
|
+
lines.push('');
|
|
94
|
+
|
|
77
95
|
lines.push('## 按模型');
|
|
78
96
|
lines.push('');
|
|
79
97
|
lines.push('| 模型 | 费用 | Tokens | 占比 |');
|
package/src/tools.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
2
|
import { dirname, isAbsolute, join, posix, resolve, win32 } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
+
import { getOpenCodeStores } from './opencode-roots.js';
|
|
4
5
|
import { findClaudeCodeDataDirs } from './claude-roots.js';
|
|
5
6
|
import { findCindyDataDirs, getCindyDataRoots } from './cindy-roots.js';
|
|
6
7
|
import { codexSessionDirs, resolveCodexHomes } from './codex-roots.js';
|
|
@@ -258,7 +259,7 @@ export const TOOLS = [
|
|
|
258
259
|
name: 'Claude Code',
|
|
259
260
|
id: 'claude-code',
|
|
260
261
|
dataDir: join(homedir(), '.claude', 'projects'),
|
|
261
|
-
detectDataDirs: findClaudeCodeDataDirs,
|
|
262
|
+
detectDataDirs: ({ extraRoots } = {}) => findClaudeCodeDataDirs(extraRootList(extraRoots?.['claude-code'])),
|
|
262
263
|
},
|
|
263
264
|
{
|
|
264
265
|
name: 'Codex CLI',
|
|
@@ -311,6 +312,9 @@ export const TOOLS = [
|
|
|
311
312
|
name: 'OpenCode',
|
|
312
313
|
id: 'opencode',
|
|
313
314
|
dataDir: join(homedir(), '.local', 'share', 'opencode'),
|
|
315
|
+
detectDataDirs: ({ extraRoots } = {}) => getOpenCodeStores({
|
|
316
|
+
extraRoots: extraRootList(extraRoots?.opencode),
|
|
317
|
+
}).map(store => store.path),
|
|
314
318
|
},
|
|
315
319
|
{
|
|
316
320
|
name: 'OpenClaw',
|