@vibe-cafe/vibe-usage 0.10.30 → 0.10.32
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 +16 -7
- package/package.json +1 -1
- package/src/claude-roots.js +12 -3
- package/src/extra-roots.js +23 -1
- package/src/index.js +35 -2
- package/src/kimi-roots.js +63 -0
- package/src/opencode-roots.js +46 -0
- package/src/parsers/claude-code.js +2 -1
- package/src/parsers/cline-sdk.js +26 -6
- package/src/parsers/cline.js +10 -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/kimi-code.js +43 -22
- package/src/parsers/opencode.js +67 -134
- package/src/parsers/zcode.js +13 -6
- package/src/tools.js +15 -9
package/README.md
CHANGED
|
@@ -50,7 +50,7 @@ npx @vibe-cafe/vibe-usage reset # Delete all data and re-upload from loca
|
|
|
50
50
|
npx @vibe-cafe/vibe-usage reset --local # Delete this host's data only and re-upload (`--host` remains a legacy alias)
|
|
51
51
|
npx @vibe-cafe/vibe-usage skill # Install skill for AI coding assistants
|
|
52
52
|
npx @vibe-cafe/vibe-usage skill --remove # Remove installed skills
|
|
53
|
-
npx @vibe-cafe/vibe-usage status #
|
|
53
|
+
npx @vibe-cafe/vibe-usage status # Config, detected tools, and what each tool has uploaded so far
|
|
54
54
|
npx @vibe-cafe/vibe-usage help --all # Full help (plain `help` shows the short version)
|
|
55
55
|
```
|
|
56
56
|
|
|
@@ -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,12 +71,12 @@ 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 |
|
|
78
78
|
| Qwen Code | `~/.qwen/tmp/` |
|
|
79
|
-
| Kimi Code | Current
|
|
79
|
+
| Kimi Code | Current `<home>/sessions/wd_<slug>_<hash>/session_<id>/agents/<agent>/wire.jsonl` (`usage.record` deltas, including retry/compaction scope and cache creation; main/subagent wires form one session), with project names from `session_index.jsonl`. Every home with that layout is scanned: the CLI home (`$KIMI_CODE_HOME`, else `~/.kimi-code`) plus the Kimi Work desktop app's embedded runtime home (`<Electron userData>/kimi-desktop/daimon-share/daimon/runtime/kimi-code/home`), which the app never writes into the CLI home; legacy `~/.kimi/sessions/` is parsed alongside (`kimi migrate` never carries usage over, so both stores are always merged) |
|
|
80
80
|
| MiniMax Code (mcode) | `$MCODE_HOME/v2/sqlite/runtime-state.sqlite` (default `~/.minimax/v2/sqlite/runtime-state.sqlite`; fixture override: `VIBE_USAGE_MCODE_DB`). Reads only allow-listed token ledger fields and session workspace/project paths, uses basename-only projects, folds cache writes into input, keeps cache reads and reasoning separate, and never selects raw/message JSON payloads. WAL/lock reads use a disposable snapshot; malformed or incompatible databases are skipped to preserve incremental state. |
|
|
81
81
|
| MiMoCode | `$MIMOCODE_HOME/data/mimocode.db`, `$XDG_DATA_HOME/mimocode/mimocode.db`, or `~/.local/share/mimocode/mimocode.db` (SQLite; exact input, output, reasoning, and cache-read tokens from assistant messages; honors `MIMOCODE_DB`; cache-write tokens are included in input usage) |
|
|
82
82
|
| Amp | `~/.local/share/amp/threads/`; cache-creation tokens are included in input usage |
|
|
@@ -84,12 +84,12 @@ 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 (CLI / SDK / Desktop app) | All variants write the same `~/.cline/data/sessions/*/*.messages.json` per-call metrics (the desktop app's manifest says `source: "desktop"`; its Electron userData holds only WebView caches), 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. |
|
|
91
91
|
| WorkBuddy | Current releases: `~/.workbuddy-ai/projects/**/*.jsonl`; legacy releases: `~/.workbuddy/projects/**/*.jsonl` (fixture/relocation override: `VIBE_USAGE_WORKBUDDY_DIRS`). Reads usage-bearing completed assistant and `function_call` records, using the routed model identifier exposed as `providerData.requestModelId`. Splits cache reads and reasoning from inclusive input/output totals, deduplicates copied record IDs, and extracts local session timing without uploading message content. |
|
|
92
|
-
| ZCode | `~/.zcode/cli/db/db.sqlite` (SQLite; reads the `message` table for per-message tokens, model, and project `cwd`/`root`, joined to `session.directory`) |
|
|
92
|
+
| ZCode | `~/.zcode/cli/db/db.sqlite` (SQLite; reads the `message` table for per-message tokens, model, and project `cwd`/`root`, joined to `session.directory`; fixture/relocation override: `VIBE_USAGE_ZCODE_DB`) |
|
|
93
93
|
| Qoder | International edition (qoder.com). IDE store `~/Library/Application Support/Qoder/SharedClientCache/cache/db/local.db` (Windows `%APPDATA%\Qoder`, Linux `~/.config/Qoder`; honors `QODER_HOME`, fixture override `VIBE_USAGE_QODER_DB`) gives real tokens from `chat_message.token_info` (prompt includes cached; split out) with `model_key` usually a routing tier, reported as `qoder-auto` / `qoder-ultimate` / … so it never collides with a priced model id; message content is never selected, and lock/schema failures fall back to a snapshot or `skipped`. CLI + desktop app transcripts `~/.qoder/projects/**/*.jsonl` (honors `QODER_CONFIG_DIR`, fixture override `VIBE_USAGE_QODER_PROJECTS`; sub-agents under `<session>/subagents/`) are credit-billed with every token field at 0, so they contribute sessions only — credits are account funding and are not collected |
|
|
94
94
|
| Qoder CN | China edition (qoder.com.cn, separate account). Same two shapes under `~/Library/Application Support/QoderCN/SharedClientCache/cache/db/local.db` (`QODER_CN_HOME` / `VIBE_USAGE_QODER_CN_DB`) and `~/.qoder-cn/projects/` (`QODERCN_CONFIG_DIR` / `VIBE_USAGE_QODER_CN_PROJECTS`); reported as source `qoder-cn` |
|
|
95
95
|
|
|
@@ -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/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
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
validateExtraRoot,
|
|
10
10
|
} from './extra-roots.js';
|
|
11
11
|
import { dim as dimText, failure, hint, smallHeader, warn } from './output.js';
|
|
12
|
+
import { loadState } from './state.js';
|
|
12
13
|
import { fetchAccount } from './api.js';
|
|
13
14
|
|
|
14
15
|
function printSmallHeader() {
|
|
@@ -17,6 +18,25 @@ function printSmallHeader() {
|
|
|
17
18
|
console.log();
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Per-source counts of everything `state.json` records as uploaded. Keys are
|
|
23
|
+
* `${source}|…` (see state.js), so the source is the first segment.
|
|
24
|
+
*/
|
|
25
|
+
function uploadedItemCounts(state) {
|
|
26
|
+
const countBySource = keys => {
|
|
27
|
+
const counts = new Map();
|
|
28
|
+
for (const key of keys) {
|
|
29
|
+
const source = key.split('|')[0];
|
|
30
|
+
counts.set(source, (counts.get(source) || 0) + 1);
|
|
31
|
+
}
|
|
32
|
+
return counts;
|
|
33
|
+
};
|
|
34
|
+
return {
|
|
35
|
+
buckets: countBySource(Object.keys(state.buckets || {})),
|
|
36
|
+
sessions: countBySource(Object.keys(state.sessions || {})),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
20
40
|
async function showStatus() {
|
|
21
41
|
const config = loadConfig();
|
|
22
42
|
console.log('\nvibe-usage status\n');
|
|
@@ -49,8 +69,21 @@ async function showStatus() {
|
|
|
49
69
|
if (detected.length === 0) {
|
|
50
70
|
console.log(' (none)\n');
|
|
51
71
|
} else {
|
|
72
|
+
// "installed" alone cannot separate "syncing fine" from "never uploaded":
|
|
73
|
+
// a tool whose data dir exists but whose buckets never reached the server
|
|
74
|
+
// reads exactly like a healthy one, which is how a stale bundled CLI stayed
|
|
75
|
+
// invisible (issue #100). state.json is the only local record of what was
|
|
76
|
+
// actually uploaded, so count it per source.
|
|
77
|
+
const uploaded = uploadedItemCounts(loadState());
|
|
52
78
|
for (const tool of detected) {
|
|
53
|
-
|
|
79
|
+
const buckets = uploaded.buckets.get(tool.id) || 0;
|
|
80
|
+
const sessions = uploaded.sessions.get(tool.id) || 0;
|
|
81
|
+
if (buckets || sessions) {
|
|
82
|
+
console.log(` ${tool.name} · 已上传 ${buckets} buckets / ${sessions} sessions`);
|
|
83
|
+
} else {
|
|
84
|
+
console.log(` ${tool.name} · 尚未上传过数据`);
|
|
85
|
+
console.log(dimText(' 本机检测到数据,但本地没有这条工具的上传记录:运行 `npx @vibe-cafe/vibe-usage` 同步'));
|
|
86
|
+
}
|
|
54
87
|
}
|
|
55
88
|
console.log();
|
|
56
89
|
}
|
|
@@ -269,7 +302,7 @@ const FULL_HELP = `
|
|
|
269
302
|
${BARE} config get <key> Get a config value
|
|
270
303
|
${BARE} config set <key> <value> Set a config value
|
|
271
304
|
${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
|
|
305
|
+
${BARE} config add-root <tool> <path> Add a Claude Code, Codex, Grok, OpenCode, Antigravity, or Pi data root
|
|
273
306
|
${BARE} config remove-root <tool> <path> Remove an added data root
|
|
274
307
|
${BARE} config roots Show added data roots as JSON
|
|
275
308
|
${BARE} help Show the short help
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { join, posix, win32 } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
|
|
5
|
+
// Root discovery for Kimi Code. The CLI keeps one data home, and the Kimi Work
|
|
6
|
+
// desktop app runs an embedded Kimi Code runtime whose home has the CLI's exact
|
|
7
|
+
// layout (issue #85), so both homes are scanned additively.
|
|
8
|
+
|
|
9
|
+
// Kimi Work's embedded runtime home, relative to the app's Electron userData.
|
|
10
|
+
const DAIMON_RUNTIME = ['daimon-share', 'daimon', 'runtime', 'kimi-code', 'home'];
|
|
11
|
+
|
|
12
|
+
/** Kimi Work's embedded Kimi Code home for this platform. */
|
|
13
|
+
export function kimiWorkCodeHome(env = process.env, platform = process.platform, home = homedir()) {
|
|
14
|
+
const pathImpl = platform === 'win32' ? win32 : posix;
|
|
15
|
+
const userData = platform === 'darwin'
|
|
16
|
+
? pathImpl.join(home, 'Library', 'Application Support')
|
|
17
|
+
: platform === 'win32'
|
|
18
|
+
? (env.APPDATA?.trim() || pathImpl.join(home, 'AppData', 'Roaming'))
|
|
19
|
+
: (env.XDG_CONFIG_HOME?.trim() || pathImpl.join(home, '.config'));
|
|
20
|
+
return pathImpl.join(userData, 'kimi-desktop', ...DAIMON_RUNTIME);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Two roots can name the same directory (symlinks, a relocated home that still
|
|
24
|
+
// resolves to the same store); scanning both would double-count every record.
|
|
25
|
+
function uniquePaths(paths) {
|
|
26
|
+
const seen = new Set();
|
|
27
|
+
const result = [];
|
|
28
|
+
for (const path of paths) {
|
|
29
|
+
let key = path;
|
|
30
|
+
try { key = realpathSync(path); } catch { /* not created yet — compare as given */ }
|
|
31
|
+
if (seen.has(key)) continue;
|
|
32
|
+
seen.add(key);
|
|
33
|
+
result.push(path);
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Kimi Code data homes to scan, primary first.
|
|
40
|
+
*
|
|
41
|
+
* `VIBE_USAGE_KIMI_CODE_DIR` (test/relocation hook) replaces discovery entirely,
|
|
42
|
+
* so a fixture never picks up the machine's real stores. Otherwise the CLI home
|
|
43
|
+
* — `$KIMI_CODE_HOME` when set, exactly like the CLI itself — is scanned
|
|
44
|
+
* alongside the Kimi Work desktop home; the desktop app never writes into the
|
|
45
|
+
* CLI home, so the two stores are independent.
|
|
46
|
+
*/
|
|
47
|
+
export function resolveKimiCodeRoots(env = process.env, platform = process.platform, home = homedir()) {
|
|
48
|
+
const override = env.VIBE_USAGE_KIMI_CODE_DIR?.trim();
|
|
49
|
+
if (override) return [override];
|
|
50
|
+
const pathImpl = platform === 'win32' ? win32 : posix;
|
|
51
|
+
return uniquePaths([
|
|
52
|
+
env.KIMI_CODE_HOME?.trim() || pathImpl.join(home, '.kimi-code'),
|
|
53
|
+
kimiWorkCodeHome(env, platform, home),
|
|
54
|
+
]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Existing Kimi Code session stores, for tool detection (`status`). */
|
|
58
|
+
export function findKimiCodeDataDirs() {
|
|
59
|
+
return uniquePaths([
|
|
60
|
+
...resolveKimiCodeRoots().map(root => join(root, 'sessions')),
|
|
61
|
+
join(homedir(), '.kimi', 'sessions'), // legacy store, always parsed alongside
|
|
62
|
+
]).filter(existsSync);
|
|
63
|
+
}
|
|
@@ -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();
|
package/src/parsers/cline-sdk.js
CHANGED
|
@@ -5,12 +5,30 @@ import { projectFromCwd, toCount } from './fs-utils.js';
|
|
|
5
5
|
// Verified with the shipped cline 3.0.61 / @cline/core 0.0.82. SQLite is only
|
|
6
6
|
// the session index: per-call accounting lives in version-1 messages artifacts.
|
|
7
7
|
// Read canonical artifacts, not DB prompt/metadata columns or provider settings.
|
|
8
|
-
|
|
8
|
+
//
|
|
9
|
+
// Two failure sizes, because they mean different things (see AGENTS.md):
|
|
10
|
+
// a *format* mismatch means the store moved on and any number we produce would
|
|
11
|
+
// be wrong, so the caller must skip the source and keep its previous upload
|
|
12
|
+
// state; an *IO* failure on one artifact (the desktop app rewrites these files
|
|
13
|
+
// in place, so half-written JSON is normal) drops only that artifact, while the
|
|
14
|
+
// rest of the store still syncs. Transient losses re-upload on the next sync.
|
|
15
|
+
function unsupported(message) {
|
|
16
|
+
const error = new Error(message);
|
|
17
|
+
error.clineUnsupported = true;
|
|
18
|
+
return error;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function readClineSdk(sessionDirs, { onWarning = () => {}, onFatal = () => {} } = {}) {
|
|
9
22
|
const copies = [];
|
|
10
23
|
for (const dir of sessionDirs) {
|
|
11
24
|
let children;
|
|
12
25
|
try { children = readdirSync(dir, { withFileTypes: true }); }
|
|
13
|
-
catch (err) {
|
|
26
|
+
catch (err) {
|
|
27
|
+
// Cannot enumerate this root: the snapshot may be missing sessions we
|
|
28
|
+
// could not even list, so the whole source is unsafe this run.
|
|
29
|
+
onFatal(`cline: 无法读取会话目录 ${dir}: ${err.message}`);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
14
32
|
for (const child of children) {
|
|
15
33
|
if (!child.isDirectory()) continue;
|
|
16
34
|
const sessionDir = join(dir, child.name);
|
|
@@ -21,10 +39,11 @@ export function readClineSdk(sessionDirs, onWarning) {
|
|
|
21
39
|
if (!files.length) continue;
|
|
22
40
|
manifest = JSON.parse(readFileSync(join(sessionDir, `${child.name}.json`), 'utf8'));
|
|
23
41
|
if (manifest?.version !== 1 || manifest.session_id !== child.name) {
|
|
24
|
-
throw
|
|
42
|
+
throw unsupported('unsupported or inconsistent Cline session manifest');
|
|
25
43
|
}
|
|
26
44
|
} catch (err) {
|
|
27
|
-
|
|
45
|
+
if (err?.clineUnsupported) onFatal(`cline: 不支持或不一致的会话清单 ${sessionDir}: ${err.message}`);
|
|
46
|
+
else onWarning(`cline: 跳过无法读取的会话目录 ${sessionDir}: ${err.message}`);
|
|
28
47
|
continue;
|
|
29
48
|
}
|
|
30
49
|
for (const file of files) {
|
|
@@ -34,7 +53,7 @@ export function readClineSdk(sessionDirs, onWarning) {
|
|
|
34
53
|
if (payload?.version !== 1 || !Array.isArray(payload.messages)
|
|
35
54
|
|| typeof payload.sessionId !== 'string'
|
|
36
55
|
|| (payload.sessionId !== child.name && payload.origin?.parentThreadId !== child.name)) {
|
|
37
|
-
throw
|
|
56
|
+
throw unsupported('unsupported or inconsistent Cline session artifact');
|
|
38
57
|
}
|
|
39
58
|
const project = projectFromCwd(manifest.workspace_root || manifest.cwd);
|
|
40
59
|
// Reduce immediately to accounting/timing metadata. No prompt, response,
|
|
@@ -68,7 +87,8 @@ export function readClineSdk(sessionDirs, onWarning) {
|
|
|
68
87
|
// shared history to the earliest original session deterministically.
|
|
69
88
|
started: Date.parse(manifest.started_at) || 0, messagesPath });
|
|
70
89
|
} catch (err) {
|
|
71
|
-
|
|
90
|
+
if (err?.clineUnsupported) onFatal(`cline: 不支持或不一致的会话文件 ${messagesPath}: ${err.message}`);
|
|
91
|
+
else onWarning(`cline: 跳过无法读取的会话文件 ${messagesPath}: ${err.message}`);
|
|
72
92
|
}
|
|
73
93
|
}
|
|
74
94
|
}
|
package/src/parsers/cline.js
CHANGED
|
@@ -7,8 +7,13 @@ import { readClineSdk } from './cline-sdk.js';
|
|
|
7
7
|
|
|
8
8
|
export async function parse() {
|
|
9
9
|
const warnings = [];
|
|
10
|
+
// Format drift or an unreadable root means we cannot describe the store:
|
|
11
|
+
// skip the source so its previous upload state survives. A single unreadable
|
|
12
|
+
// artifact only drops that artifact (it re-uploads on the next sync).
|
|
13
|
+
let fatal = false;
|
|
10
14
|
const onWarning = message => warnings.push(message);
|
|
11
|
-
const
|
|
15
|
+
const onFatal = message => { fatal = true; warnings.push(message); };
|
|
16
|
+
const { legacyRoots: extDirs, sdkSessionDirs } = findClineStores({ onWarning: onFatal });
|
|
12
17
|
|
|
13
18
|
const entries = [];
|
|
14
19
|
const events = [];
|
|
@@ -90,8 +95,9 @@ export async function parse() {
|
|
|
90
95
|
}
|
|
91
96
|
}
|
|
92
97
|
|
|
93
|
-
const sdk = readClineSdk(sdkSessionDirs, onWarning);
|
|
94
|
-
if (
|
|
98
|
+
const sdk = readClineSdk(sdkSessionDirs, { onWarning, onFatal });
|
|
99
|
+
if (fatal) return { buckets: [], sessions: [], skipped: true, warnings };
|
|
95
100
|
return { buckets: aggregateToBuckets([...entries, ...sdk.entries]),
|
|
96
|
-
sessions: extractSessions([...events, ...sdk.events])
|
|
101
|
+
sessions: extractSessions([...events, ...sdk.events]),
|
|
102
|
+
warnings };
|
|
97
103
|
}
|
|
@@ -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/kimi-code.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
|
1
|
+
import { readdirSync, readFileSync, existsSync, realpathSync } from 'node:fs';
|
|
2
2
|
import { join, basename } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { createHash } from 'node:crypto';
|
|
5
5
|
import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
6
|
+
import { resolveKimiCodeRoots } from '../kimi-roots.js';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Kimi Code CLI parser. MoonshotAI/kimi-cli (a.k.a. "Kimi Code").
|
|
@@ -19,8 +20,13 @@ import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
|
19
20
|
* sum to the session total. The model name rides on each record — no config
|
|
20
21
|
* lookup needed. User turns are `turn.prompt` with origin.kind === "user".
|
|
21
22
|
* The real working directory per session is recorded in
|
|
22
|
-
*
|
|
23
|
+
* <home>/session_index.jsonl -> {sessionId, sessionDir, workDir}
|
|
23
24
|
* which gives an accurate project name (last path component of workDir).
|
|
25
|
+
* Several homes share this layout and are all scanned (see kimi-roots.js):
|
|
26
|
+
* the CLI home ($KIMI_CODE_HOME, else ~/.kimi-code) and the Kimi Work
|
|
27
|
+
* desktop app's embedded runtime home. The desktop app never writes into the
|
|
28
|
+
* CLI home, so the stores are independent and merging them cannot
|
|
29
|
+
* double-count.
|
|
24
30
|
*
|
|
25
31
|
* 2. Legacy ("~/.kimi", protocol 1.1 / 1.9). Sessions live at
|
|
26
32
|
* ~/.kimi/sessions/<md5(workdir)>/<session-id>/wire.jsonl
|
|
@@ -39,15 +45,12 @@ import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
|
39
45
|
// Current format: ~/.kimi-code
|
|
40
46
|
// ---------------------------------------------------------------------------
|
|
41
47
|
|
|
42
|
-
// VIBE_USAGE_KIMI_CODE_DIR overrides the root (test hook). Otherwise
|
|
43
|
-
// the
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|| join(homedir(), '.kimi-code');
|
|
49
|
-
const KIMI_CODE_SESSIONS_DIR = join(KIMI_CODE_DIR, 'sessions');
|
|
50
|
-
const KIMI_CODE_SESSION_INDEX = join(KIMI_CODE_DIR, 'session_index.jsonl');
|
|
48
|
+
// VIBE_USAGE_KIMI_CODE_DIR overrides the root (test hook). Otherwise every root
|
|
49
|
+
// from kimi-roots.js is scanned — the CLI home resolved the same way the CLI
|
|
50
|
+
// itself resolves it ($KIMI_CODE_HOME, then ~/.kimi-code) plus the Kimi Work
|
|
51
|
+
// desktop app's embedded runtime home (issue #85), which uses this exact
|
|
52
|
+
// layout. Ignoring KIMI_CODE_HOME means users with a custom home get zero usage
|
|
53
|
+
// parsed; ignoring the desktop home means Kimi Work users get zero usage parsed.
|
|
51
54
|
|
|
52
55
|
function projectNameFromPath(path) {
|
|
53
56
|
if (typeof path !== 'string' || !path) return null;
|
|
@@ -55,17 +58,17 @@ function projectNameFromPath(path) {
|
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
/**
|
|
58
|
-
* Map each session directory (absolute path) to a project name, read from
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
+
* Map each session directory (absolute path) to a project name, read from the
|
|
62
|
+
* home's session_index.jsonl. Falls back gracefully if the file is missing or
|
|
63
|
+
* malformed — callers default to the wd_ bucket name.
|
|
61
64
|
*/
|
|
62
|
-
function loadSessionIndex() {
|
|
65
|
+
function loadSessionIndex(indexPath) {
|
|
63
66
|
const map = new Map();
|
|
64
|
-
if (!existsSync(
|
|
67
|
+
if (!existsSync(indexPath)) return map;
|
|
65
68
|
|
|
66
69
|
let content;
|
|
67
70
|
try {
|
|
68
|
-
content = readFileSync(
|
|
71
|
+
content = readFileSync(indexPath, 'utf-8');
|
|
69
72
|
} catch {
|
|
70
73
|
return map;
|
|
71
74
|
}
|
|
@@ -141,15 +144,35 @@ function findKimiCodeWireFiles(baseDir) {
|
|
|
141
144
|
return results;
|
|
142
145
|
}
|
|
143
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Every wire file to parse, with its project name, across all Kimi Code homes.
|
|
149
|
+
* Two roots can resolve to the same store (symlink, relocated home), so the
|
|
150
|
+
* same physical wire file is returned once.
|
|
151
|
+
*/
|
|
152
|
+
function findKimiCodeWireFilesInAllRoots() {
|
|
153
|
+
const results = [];
|
|
154
|
+
const seen = new Set();
|
|
155
|
+
for (const root of resolveKimiCodeRoots()) {
|
|
156
|
+
const sessionIndex = loadSessionIndex(join(root, 'session_index.jsonl'));
|
|
157
|
+
for (const { wireFile, sessionDir, bucketProject } of findKimiCodeWireFiles(join(root, 'sessions'))) {
|
|
158
|
+
let identity = wireFile;
|
|
159
|
+
try { identity = realpathSync(wireFile); } catch { /* keep the literal path */ }
|
|
160
|
+
if (seen.has(identity)) continue;
|
|
161
|
+
seen.add(identity);
|
|
162
|
+
results.push({ wireFile, sessionDir, project: sessionIndex.get(sessionDir) || bucketProject || 'unknown' });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return results;
|
|
166
|
+
}
|
|
167
|
+
|
|
144
168
|
function parseKimiCode() {
|
|
145
|
-
const wireFiles =
|
|
169
|
+
const wireFiles = findKimiCodeWireFilesInAllRoots();
|
|
146
170
|
if (wireFiles.length === 0) return null;
|
|
147
171
|
|
|
148
|
-
const sessionIndex = loadSessionIndex();
|
|
149
172
|
const entries = [];
|
|
150
173
|
const sessionEvents = [];
|
|
151
174
|
|
|
152
|
-
for (const { wireFile, sessionDir,
|
|
175
|
+
for (const { wireFile, sessionDir, project } of wireFiles) {
|
|
153
176
|
let content;
|
|
154
177
|
try {
|
|
155
178
|
content = readFileSync(wireFile, 'utf-8');
|
|
@@ -157,8 +180,6 @@ function parseKimiCode() {
|
|
|
157
180
|
continue;
|
|
158
181
|
}
|
|
159
182
|
|
|
160
|
-
const project = sessionIndex.get(sessionDir) || bucketProject || 'unknown';
|
|
161
|
-
|
|
162
183
|
for (const line of content.split('\n')) {
|
|
163
184
|
if (!line.trim()) continue;
|
|
164
185
|
let evt;
|
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/parsers/zcode.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
|
-
import {
|
|
3
|
-
import { homedir } from 'node:os';
|
|
2
|
+
import { basename } from 'node:path';
|
|
4
3
|
import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
5
4
|
import { queryDbJson, sqliteUnavailableError, isSqliteUnavailableError } from './sqlite.js';
|
|
5
|
+
import { getZcodeDbPath } from '../tools.js';
|
|
6
6
|
|
|
7
7
|
// ZCode (z.ai / Zhipu's coding agent) stores everything in a SQLite database
|
|
8
8
|
// at ~/.zcode/cli/db/db.sqlite. The `message` table is the canonical source:
|
|
@@ -11,7 +11,9 @@ import { queryDbJson, sqliteUnavailableError, isSqliteUnavailableError } from '.
|
|
|
11
11
|
// than the parallel `model_usage` ledger because `message` gives us BOTH session
|
|
12
12
|
// timing (user + assistant rows) and token usage in one pass, with the project
|
|
13
13
|
// path attached to each message.
|
|
14
|
-
|
|
14
|
+
export function resolveZcodeDbPath(env = process.env) {
|
|
15
|
+
return getZcodeDbPath(env);
|
|
16
|
+
}
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
19
|
* Project name from a ZCode message's path. ZCode records both `cwd` and `root`;
|
|
@@ -26,15 +28,20 @@ function projectName(root, cwd, sessionDir) {
|
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export async function parse() {
|
|
29
|
-
|
|
31
|
+
const dbPath = resolveZcodeDbPath();
|
|
32
|
+
if (!existsSync(dbPath)) return { buckets: [], sessions: [] };
|
|
30
33
|
|
|
31
34
|
// Join each message to its session so we can fall back to the session's
|
|
32
35
|
// directory when an individual message has no path (older rows, lite agents).
|
|
36
|
+
// ZCode renamed the assistant message's model keys from `modelID` /
|
|
37
|
+
// `providerID` to `modelId` / `providerId`; read both spellings so neither
|
|
38
|
+
// build reports every bucket as `unknown`.
|
|
33
39
|
const query = `SELECT
|
|
34
40
|
m.session_id AS sessionId,
|
|
35
41
|
m.time_created AS created,
|
|
36
42
|
json_extract(m.data, '$.role') AS role,
|
|
37
43
|
json_extract(m.data, '$.modelID') AS modelID,
|
|
44
|
+
json_extract(m.data, '$.modelId') AS modelId,
|
|
38
45
|
json_extract(m.data, '$.tokens') AS tokens,
|
|
39
46
|
json_extract(m.data, '$.path.root') AS pathRoot,
|
|
40
47
|
json_extract(m.data, '$.path.cwd') AS pathCwd,
|
|
@@ -44,7 +51,7 @@ export async function parse() {
|
|
|
44
51
|
|
|
45
52
|
let rows;
|
|
46
53
|
try {
|
|
47
|
-
rows = queryDbJson(
|
|
54
|
+
rows = queryDbJson(dbPath, query);
|
|
48
55
|
} catch (err) {
|
|
49
56
|
if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('ZCode');
|
|
50
57
|
throw err;
|
|
@@ -88,7 +95,7 @@ export async function parse() {
|
|
|
88
95
|
|
|
89
96
|
entries.push({
|
|
90
97
|
source: 'zcode',
|
|
91
|
-
model: row.modelID || 'unknown',
|
|
98
|
+
model: row.modelID || row.modelId || 'unknown',
|
|
92
99
|
project,
|
|
93
100
|
timestamp,
|
|
94
101
|
inputTokens: (tokens.input || 0) - cachedInput,
|
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';
|
|
@@ -14,6 +15,7 @@ import { findClineDataDirs } from './cline-roots.js';
|
|
|
14
15
|
import { findColaDataDirs, getColaSessionsDir } from './cola-roots.js';
|
|
15
16
|
import { findCraftDataDirs } from './craft-roots.js';
|
|
16
17
|
import { findHermesDataDirs, getHermesHome } from './hermes-roots.js';
|
|
18
|
+
import { findKimiCodeDataDirs } from './kimi-roots.js';
|
|
17
19
|
import { findOmpDataDirs, findPiDataDirs } from './pi-roots.js';
|
|
18
20
|
import { findQoderDataDirs, getQoderProjectsDir } from './qoder-roots.js';
|
|
19
21
|
import { findWorkbuddyDataDirs } from './workbuddy-roots.js';
|
|
@@ -123,13 +125,8 @@ export function findCodexDataDirs(codexExtraHome, extraRoots = []) {
|
|
|
123
125
|
}
|
|
124
126
|
|
|
125
127
|
// Kimi Code moved its store from ~/.kimi to ~/.kimi-code; recognize either so
|
|
126
|
-
// users on either version are detected.
|
|
127
|
-
|
|
128
|
-
return [
|
|
129
|
-
join(homedir(), '.kimi-code', 'sessions'),
|
|
130
|
-
join(homedir(), '.kimi', 'sessions'),
|
|
131
|
-
].filter(existsSync);
|
|
132
|
-
}
|
|
128
|
+
// users on either version are detected. Kimi Work's embedded runtime home is
|
|
129
|
+
// recognized too — see kimi-roots.js, which the parser uses as well.
|
|
133
130
|
|
|
134
131
|
/** DeepSeek Harness home: DSH_HOME env (same as the dsh CLI) or ~/.dsh. */
|
|
135
132
|
export function getDshHome(env = process.env) {
|
|
@@ -175,6 +172,12 @@ export function getMimocodeDbPath(env = process.env) {
|
|
|
175
172
|
return isAbsolute(env.MIMOCODE_DB) ? env.MIMOCODE_DB : join(dataDir, env.MIMOCODE_DB);
|
|
176
173
|
}
|
|
177
174
|
|
|
175
|
+
export function getZcodeDbPath(env = process.env, home = homedir()) {
|
|
176
|
+
const override = env.VIBE_USAGE_ZCODE_DB?.trim();
|
|
177
|
+
if (override) return isAbsolute(override) ? override : resolve(override);
|
|
178
|
+
return join(home, '.zcode', 'cli', 'db', 'db.sqlite');
|
|
179
|
+
}
|
|
180
|
+
|
|
178
181
|
export function findAntigravityDataDirs(extraRoots = []) {
|
|
179
182
|
return [...new Set([
|
|
180
183
|
...antigravityConversationDirs(homedir()).map(dirname),
|
|
@@ -258,7 +261,7 @@ export const TOOLS = [
|
|
|
258
261
|
name: 'Claude Code',
|
|
259
262
|
id: 'claude-code',
|
|
260
263
|
dataDir: join(homedir(), '.claude', 'projects'),
|
|
261
|
-
detectDataDirs: findClaudeCodeDataDirs,
|
|
264
|
+
detectDataDirs: ({ extraRoots } = {}) => findClaudeCodeDataDirs(extraRootList(extraRoots?.['claude-code'])),
|
|
262
265
|
},
|
|
263
266
|
{
|
|
264
267
|
name: 'Codex CLI',
|
|
@@ -311,6 +314,9 @@ export const TOOLS = [
|
|
|
311
314
|
name: 'OpenCode',
|
|
312
315
|
id: 'opencode',
|
|
313
316
|
dataDir: join(homedir(), '.local', 'share', 'opencode'),
|
|
317
|
+
detectDataDirs: ({ extraRoots } = {}) => getOpenCodeStores({
|
|
318
|
+
extraRoots: extraRootList(extraRoots?.opencode),
|
|
319
|
+
}).map(store => store.path),
|
|
314
320
|
},
|
|
315
321
|
{
|
|
316
322
|
name: 'OpenClaw',
|
|
@@ -433,7 +439,7 @@ export const TOOLS = [
|
|
|
433
439
|
{
|
|
434
440
|
name: 'ZCode',
|
|
435
441
|
id: 'zcode',
|
|
436
|
-
dataDir:
|
|
442
|
+
dataDir: getZcodeDbPath(),
|
|
437
443
|
},
|
|
438
444
|
];
|
|
439
445
|
|