@vibe-cafe/vibe-usage 0.10.21 → 0.10.23

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 CHANGED
@@ -72,14 +72,16 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
72
72
  | Amp | `~/.local/share/amp/threads/`; cache-creation tokens are included in input usage |
73
73
  | Droid | `~/.factory/sessions/` |
74
74
  | DeepSeek Harness | `$DSH_HOME/sessions/` (default `~/.dsh`, fixture/relocation override: `VIBE_USAGE_DSH_SESSIONS`). Reads multi-frame Zstandard `session.jsonl.zstd` logs (built-in `node:zlib` zstd on Node ≥ 22.15, `zstd` CLI fallback) and plain `session.jsonl` logs. Usage comes from `assistant/message`: cache writes join uncached input, cache reads remain separate, and reasoning is split out of inclusive output. Fork/subagent history is de-duplicated from the immutable header: `parentSession` identifies the source and `seedLength` gives the exact leading event boundary. Inherited messages are skipped only when matching source seqs remain in the parent file; missing parents fail open. `session/end-seed` positions are not used because resumes can append the marker after real history. |
75
- | Hermes | `~/.hermes/state.db` + `~/.hermes/profiles/<name>/state.db` (SQLite, multi-profile) |
75
+ | Hermes | `$HERMES_HOME/state.db` (default `~/.hermes/state.db`) + `profiles/<name>/state.db` (SQLite, multi-profile). 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. |
76
76
  | 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` |
77
77
  | Cline | Standalone `~/.cline/` plus `<host>/User/globalStorage/saoudrizwan.claude-dev/` across VSCode-fork hosts; migrated copies are deduplicated and empty leftover extension stores no longer count as installed |
78
78
  | Roo Code | `<host>/User/globalStorage/rooveterinaryinc.roo-cline/{tasks/_index.json,tasks/<id>/{history_item,ui_messages}.json}` (walks all VSCode-fork hosts) |
79
79
  | 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. |
80
- | Antigravity | App 2.0 `~/.gemini/antigravity/conversations/*.db` and `agy` CLI `~/.gemini/antigravity-cli/conversations/*.db`, plus the same paths below explicitly added alternate Homes, are parsed offline (tokens, real model display name when present, project, sessions). Gemini 3.7 CLI blobs omit `chatStartMetadata.createdAt` and `modelDisplayName`; usage still comes from `gen_metadata`, timestamps fall back to `steps.metadata` at the same idx, and the model name falls back to `responseModel`. Legacy App `.pb` history falls back to Connect RPC only for the primary App store while the language server is running |
80
+ | 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. |
81
81
  | 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. |
82
82
  | ZCode | `~/.zcode/cli/db/db.sqlite` (SQLite; reads the `message` table for per-message tokens, model, and project `cwd`/`root`, joined to `session.directory`) |
83
+ | 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 |
84
+ | 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` |
83
85
 
84
86
  ## How It Works
85
87
 
@@ -93,6 +95,24 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
93
95
  - 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
94
96
  - For continuous syncing, use `npx @vibe-cafe/vibe-usage daemon` or the [Vibe Usage Mac app](https://github.com/vibe-cafe/vibe-usage-app)
95
97
 
98
+ ## Cursor 网络排查
99
+
100
+ Cursor 用量需要从 `cursor.com` 下载 CSV。`Cursor usage export skipped (network: …)` 表示这次下载失败;CLI 会保留 Cursor 的历史同步状态,下次同步重新尝试。
101
+
102
+ - `ENOTFOUND` / `EAI_AGAIN`:检查 DNS 和终端网络。
103
+ - `UND_ERR_CONNECT_TIMEOUT` / `ETIMEDOUT` / `ECONNRESET`:检查到 Cursor 的连接及终端代理。浏览器或 Cursor 应用能联网,不代表 Node.js 使用了相同代理。
104
+ - `CERT_*` / `UNABLE_TO_VERIFY_LEAF_SIGNATURE` 等:检查系统时间和代理或公司网络的 CA 证书配置;自定义 CA 可通过 `NODE_EXTRA_CA_CERTS` 指定。
105
+ - `timeout after …ms`:导出或下载超时,稍后重试;网络较慢时可设置 `VIBE_USAGE_CURSOR_FETCH_TIMEOUT_MS=60000`。
106
+ - `Cursor session rejected`:在 Cursor 的 Account 设置中重新登录,再同步。
107
+
108
+ 需要 HTTP/HTTPS 代理时,Node.js **22.21+ 或 24.5+** 可用 `NODE_USE_ENV_PROXY=1` 启用环境变量代理([Node.js 官方说明](https://nodejs.org/en/learn/http/enterprise-network-configuration))。以下为 macOS/Linux 终端示例,代理地址须替换为实际地址:
109
+
110
+ ```bash
111
+ HTTPS_PROXY=http://127.0.0.1:7890 NODE_USE_ENV_PROXY=1 npx @vibe-cafe/vibe-usage sync
112
+ ```
113
+
114
+ 这些环境变量只影响当前命令;已经运行的桌面应用和后台服务有各自的进程环境。持续失败时,请提供完整的 Cursor 报错、操作系统、`node -v` 和 CLI 版本,以便区分具体原因。
115
+
96
116
  ## Trust Model
97
117
 
98
118
  vibe-usage parses **local tool logs and local application state** on a machine the user fully controls. The reported data is self-reported telemetry — local logs, parsers, and upload requests can all be modified by the user.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.10.21",
3
+ "version": "0.10.23",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -86,6 +86,7 @@ export function antigravityConversationDirs(value) {
86
86
  return [
87
87
  join(root, '.gemini', 'antigravity', 'conversations'),
88
88
  join(root, '.gemini', 'antigravity-cli', 'conversations'),
89
+ join(root, '.gemini', 'antigravity-ide', 'conversations'),
89
90
  ];
90
91
  }
91
92
 
@@ -20,9 +20,8 @@ import { listDbCascades, readDbUsageRecords, readDbWorkspaceUri, readDbSessionEv
20
20
  */
21
21
 
22
22
  const SOURCE = 'antigravity';
23
- const CONVERSATIONS_DIR = join(homedir(), '.gemini', 'antigravity', 'conversations');
24
- // `agy` CLI stores conversations in a separate, App-independent directory.
25
- const CLI_CONVERSATIONS_DIR = join(homedir(), '.gemini', 'antigravity-cli', 'conversations');
23
+ // App, CLI, and the standalone IDE each have their own conversation store.
24
+ const CONVERSATIONS_DIRS = antigravityConversationDirs(homedir());
26
25
 
27
26
  // User sources → role 'user'; Model source → role 'assistant'; System sources → skip
28
27
  const USER_SOURCES = new Set([
@@ -33,24 +32,25 @@ const ASSISTANT_SOURCES = new Set([
33
32
  'CORTEX_STEP_SOURCE_MODEL',
34
33
  ]);
35
34
 
36
- // ── Process discovery (single instance) ──────────────────────────────
35
+ // ── Process discovery ───────────────────────────────────────────────
37
36
 
38
37
  const IS_WIN = process.platform === 'win32';
39
38
 
40
39
  /**
41
- * Find ONE running language server process with a CSRF token.
42
- * Returns { pid, csrfToken } or null.
40
+ * Find running language servers with CSRF tokens. The App and standalone IDE
41
+ * can run together, and one server cannot read the other's legacy cascades.
43
42
  */
44
- function findLanguageServer() {
43
+ function findLanguageServers() {
45
44
  try {
46
- return IS_WIN ? findLanguageServerWin() : findLanguageServerUnix();
45
+ return IS_WIN ? findLanguageServersWin() : findLanguageServersUnix();
47
46
  } catch {
48
- return null;
47
+ return [];
49
48
  }
50
49
  }
51
50
 
52
- function findLanguageServerUnix() {
51
+ function findLanguageServersUnix() {
53
52
  const out = execSync("ps aux | grep -i 'antigravity.*language_server'", { encoding: 'utf-8', timeout: 5000 });
53
+ const servers = [];
54
54
  for (const line of out.split('\n')) {
55
55
  if (!line.trim()) continue;
56
56
  if (line.includes('grep')) continue;
@@ -59,19 +59,19 @@ function findLanguageServerUnix() {
59
59
  const pid = parts[1];
60
60
  const csrfMatch = line.match(/--csrf_token\s+([0-9a-f-]+)/);
61
61
  const csrfToken = csrfMatch ? csrfMatch[1] : '';
62
- if (csrfToken) return { pid, csrfToken };
62
+ if (csrfToken) servers.push({ pid, csrfToken });
63
63
  }
64
- return null;
64
+ return servers;
65
65
  }
66
66
 
67
- function findLanguageServerWin() {
67
+ function findLanguageServersWin() {
68
68
  // Prefer PowerShell/CIM: wmic is disabled by default on Windows 11 23H2+
69
69
  // and removed entirely from 25H2 onward. Fall back to wmic for old/stripped
70
70
  // environments without PowerShell. Each probe is independently time-boxed and
71
71
  // failures are swallowed, so a missing/hung tool never blocks the next one or
72
72
  // the parsers that run after antigravity.
73
73
  const out = queryProcessesWinPowerShell() ?? queryProcessesWinWmic();
74
- if (!out) return null;
74
+ if (!out) return [];
75
75
  return parseWinProcessList(out);
76
76
  }
77
77
 
@@ -117,20 +117,20 @@ function queryProcessesWinWmic() {
117
117
 
118
118
  /**
119
119
  * Parse "ProcessId=..." / "CommandLine=..." records (from either PowerShell or
120
- * wmic /format:list) and return the first language_server that carries a
121
- * --csrf_token, or null. PowerShell emits an explicit "---" separator per
120
+ * wmic /format:list) and return each language_server that carries a
121
+ * --csrf_token. PowerShell emits an explicit "---" separator per
122
122
  * process; wmic does not and may emit the two fields in either order, so a
123
123
  * record also ends whenever a field we've already captured reappears.
124
124
  */
125
- function parseWinProcessList(out) {
125
+ export function parseWinProcessList(out) {
126
+ const servers = [];
126
127
  let pid = '';
127
128
  let cmdLine = '';
128
129
  const finish = () => {
129
130
  if (pid && cmdLine && !/WMIC\.exe|powershell\.exe|pwsh\.exe/i.test(cmdLine)) {
130
131
  const csrfMatch = cmdLine.match(/--csrf_token\s+([0-9a-f-]+)/);
131
- if (csrfMatch) return { pid, csrfToken: csrfMatch[1] };
132
+ if (csrfMatch) servers.push({ pid, csrfToken: csrfMatch[1] });
132
133
  }
133
- return null;
134
134
  };
135
135
  const reset = () => { pid = ''; cmdLine = ''; };
136
136
  for (const line of out.split('\n')) {
@@ -140,14 +140,14 @@ function parseWinProcessList(out) {
140
140
  // Record boundary: explicit "---", or a field that would overwrite one we
141
141
  // already hold (next process began without a separator, e.g. wmic output).
142
142
  if (trimmed === '---' || (isPid && pid) || (isCmd && cmdLine)) {
143
- const found = finish();
144
- if (found) return found;
143
+ finish();
145
144
  reset();
146
145
  }
147
146
  if (isPid) pid = trimmed.slice('ProcessId='.length);
148
147
  else if (isCmd) cmdLine = trimmed.slice('CommandLine='.length);
149
148
  }
150
- return finish();
149
+ finish();
150
+ return servers;
151
151
  }
152
152
 
153
153
  function findListeningPorts(pid) {
@@ -296,7 +296,7 @@ function projectFromUri(uri) {
296
296
  * List cascade IDs backed by a legacy `.pb` file (App history). `.db` cascades
297
297
  * are handled separately via offline parsing.
298
298
  */
299
- function listPbCascades(conversationsDir = CONVERSATIONS_DIR) {
299
+ function listPbCascades(conversationsDir) {
300
300
  try {
301
301
  const out = [];
302
302
  for (const f of readdirSync(conversationsDir)) {
@@ -320,6 +320,7 @@ function modelFromRecord(rec) {
320
320
  export async function parse({ extraRoots = [] } = {}) {
321
321
  const entries = [];
322
322
  const sessionEvents = [];
323
+ const warnings = [];
323
324
  const seenResponseIds = new Set();
324
325
 
325
326
  const extraDirs = [];
@@ -356,7 +357,7 @@ export async function parse({ extraRoots = [] } = {}) {
356
357
  const fixtureDirs = process.env.VIBE_USAGE_ANTIGRAVITY_DIRS?.trim();
357
358
  const defaultDirs = fixtureDirs
358
359
  ? fixtureDirs.split(delimiter).filter(Boolean)
359
- : [CONVERSATIONS_DIR, CLI_CONVERSATIONS_DIR];
360
+ : CONVERSATIONS_DIRS;
360
361
  const strictDirs = new Set(extraDirs);
361
362
  const conversationDirs = [...new Set([...defaultDirs, ...extraDirs])];
362
363
  const candidates = [];
@@ -444,13 +445,14 @@ export async function parse({ extraRoots = [] } = {}) {
444
445
  }
445
446
 
446
447
  // ── Path 2: RPC fallback, only for legacy .pb cascades not already parsed ──
447
- const pbDir = defaultDirs[0] || CONVERSATIONS_DIR;
448
- const pbCascades = listPbCascades(pbDir).filter((id) => !dbHandled.has(id));
448
+ const pbCascades = [...new Set(defaultDirs.flatMap(listPbCascades))]
449
+ .filter((id) => !dbHandled.has(id));
449
450
  if (pbCascades.length > 0) {
450
- const server = findLanguageServer();
451
- const ports = server ? findListeningPorts(server.pid) : [];
452
- const baseUrl = ports.length > 0 ? await probeHttpPort(ports, server.csrfToken) : null;
453
- if (baseUrl) {
451
+ const pending = new Set(pbCascades);
452
+ for (const server of findLanguageServers()) {
453
+ const ports = findListeningPorts(server.pid);
454
+ const baseUrl = ports.length > 0 ? await probeHttpPort(ports, server.csrfToken) : null;
455
+ if (!baseUrl) continue;
454
456
  const rpc = (method, body) =>
455
457
  rpcPost(
456
458
  baseUrl,
@@ -459,7 +461,7 @@ export async function parse({ extraRoots = [] } = {}) {
459
461
  server.csrfToken,
460
462
  );
461
463
 
462
- for (const cascadeId of pbCascades) {
464
+ for (const cascadeId of pending) {
463
465
  let resp;
464
466
  try {
465
467
  resp = await rpc('GetCascadeTrajectory', { cascadeId });
@@ -468,6 +470,7 @@ export async function parse({ extraRoots = [] } = {}) {
468
470
  }
469
471
  const trajectory = resp?.trajectory;
470
472
  if (!trajectory) continue;
473
+ pending.delete(cascadeId);
471
474
 
472
475
  const steps = trajectory.steps || [];
473
476
  const metadataList = trajectory.generatorMetadata || [];
@@ -519,11 +522,16 @@ export async function parse({ extraRoots = [] } = {}) {
519
522
  sessionEvents.push({ sessionId: cascadeId, source: SOURCE, project, timestamp: ts, role });
520
523
  }
521
524
  }
525
+ if (pending.size === 0) break;
526
+ }
527
+ if (pending.size > 0) {
528
+ warnings.push(`antigravity: ${pending.size} 个旧格式会话未能读取,请打开对应的 Antigravity IDE/App 后重新同步;已保留上次同步状态。`);
522
529
  }
523
530
  }
524
531
 
525
532
  return {
526
533
  buckets: aggregateToBuckets(entries),
527
534
  sessions: extractSessions(sessionEvents),
535
+ ...(warnings.length > 0 ? { skipped: true, warnings } : {}),
528
536
  };
529
537
  }
@@ -82,6 +82,41 @@ export function resolveCursorFetchTimeout(value) {
82
82
 
83
83
  const FETCH_TIMEOUT_MS = resolveCursorFetchTimeout(process.env.VIBE_USAGE_CURSOR_FETCH_TIMEOUT_MS);
84
84
 
85
+ function usageNetworkError(error, signal) {
86
+ // Node fetch usually exposes only "fetch failed" at the top level. DNS,
87
+ // TLS and connection codes live in cause (or AggregateError.errors when
88
+ // both IPv4 and IPv6 fail). Report codes, not raw messages that may contain
89
+ // URLs with proxy credentials or authentication headers.
90
+ const codes = new Set();
91
+ const seen = new Set();
92
+ function visit(err) {
93
+ if (!err || typeof err !== 'object' || seen.has(err)) return;
94
+ seen.add(err);
95
+ if (typeof err.code === 'string' && /^[A-Z][A-Z0-9_]{1,63}$/.test(err.code)) {
96
+ codes.add(err.code);
97
+ }
98
+ visit(err.cause);
99
+ if (Array.isArray(err.errors)) err.errors.forEach(visit);
100
+ }
101
+ visit(error);
102
+
103
+ const timedOut = error?.name === 'TimeoutError' || signal.reason?.name === 'TimeoutError';
104
+ const reason = timedOut
105
+ ? `timeout after ${FETCH_TIMEOUT_MS}ms`
106
+ : `network: ${[...codes].join(', ') || 'fetch failed'}`;
107
+ let hint = '请检查终端网络和代理配置;浏览器能访问 Cursor 不代表 Node.js 使用了相同代理。';
108
+ if (timedOut) {
109
+ hint = 'Cursor 用量导出超时,请稍后重试;可通过 VIBE_USAGE_CURSOR_FETCH_TIMEOUT_MS 增大导出等待时间。';
110
+ } else if (codes.has('ENOTFOUND') || codes.has('EAI_AGAIN')) {
111
+ hint = '域名解析失败,请检查 DNS 和终端代理配置。';
112
+ } else if ([...codes].some(code => /CERT|SELF_SIGNED|UNABLE_TO_VERIFY|ERR_TLS_/.test(code))) {
113
+ hint = 'TLS 证书校验失败,请检查系统时间及代理或公司网络的 CA 证书配置。';
114
+ }
115
+ const err = new Error(`Cursor usage export skipped (${reason}). ${hint}`);
116
+ err.skip = true;
117
+ return err;
118
+ }
119
+
85
120
  async function fetchUsageCsv(token) {
86
121
  const url = `${(process.env.CURSOR_WEB_BASE_URL?.trim() || 'https://cursor.com').replace(/\/+$/, '')}/api/dashboard/export-usage-events-csv?strategy=tokens`;
87
122
  const sub = decodeJwtSub(token);
@@ -110,20 +145,19 @@ async function fetchUsageCsv(token) {
110
145
  const failures = [];
111
146
  for (const headers of attempts) {
112
147
  let resp;
148
+ const signal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
113
149
  try {
114
150
  resp = await fetch(url, {
115
151
  headers: { ...baseHeaders, ...headers },
116
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
152
+ signal,
117
153
  });
154
+ // The export may time out or disconnect after headers have arrived.
155
+ // Keep body consumption under the same deadline and soft-skip handling.
156
+ if (resp.ok) return await resp.text();
118
157
  } catch (e) {
119
- // Hard-fail on network/timeout: stop trying further headers (won't fix
120
- // a downed host) and signal a soft skip to the caller.
121
- const reason = e.name === 'TimeoutError' ? 'timeout' : `network: ${e.message}`;
122
- const err = new Error(`Cursor usage export skipped (${reason})`);
123
- err.skip = true;
124
- throw err;
158
+ // Changing credentials cannot repair a network failure.
159
+ throw usageNetworkError(e, signal);
125
160
  }
126
- if (resp.ok) return await resp.text();
127
161
  failures.push(`${resp.status} ${resp.statusText}`);
128
162
  // Only auth rejections are worth retrying with different credentials.
129
163
  // 429/5xx are transient server-side states — soft-skip like network errors
@@ -198,7 +232,7 @@ export async function parse() {
198
232
  try {
199
233
  csv = await fetchUsageCsv(token);
200
234
  } catch (err) {
201
- // Network/timeout → silent skip (avoid noisy daemon logs every 5 min).
235
+ // Network/timeout → skip with diagnostics (quiet sync suppresses these).
202
236
  // Auth failure → bubble up so user sees they need to re-login in Cursor.
203
237
  // Tell sync.js this was not a successful empty snapshot so it preserves
204
238
  // Cursor's incremental state instead of pruning it as dead history.
@@ -2,6 +2,7 @@ import { existsSync, readdirSync, statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { aggregateToBuckets, extractSessions } from './aggregate.js';
5
+ import { toCount } from './fs-utils.js';
5
6
  import { queryDbJson, sqliteUnavailableError, isSqliteUnavailableError } from './sqlite.js';
6
7
 
7
8
  const HERMES_HOME = process.env.HERMES_HOME || join(homedir(), '.hermes');
@@ -26,6 +27,8 @@ export async function parse() {
26
27
  for (const { path: dbPath, profile } of dbs) {
27
28
  let sessionRows;
28
29
  try {
30
+ const columns = new Set(queryDb(dbPath, 'PRAGMA table_info(sessions)').map(row => row.name));
31
+ const cacheWriteColumn = columns.has('cache_write_tokens') ? 'cache_write_tokens' : '0';
29
32
  sessionRows = queryDb(dbPath, `SELECT
30
33
  id,
31
34
  model,
@@ -33,9 +36,11 @@ export async function parse() {
33
36
  input_tokens as inputTokens,
34
37
  output_tokens as outputTokens,
35
38
  cache_read_tokens as cacheReadTokens,
39
+ ${cacheWriteColumn} as cacheWriteTokens,
36
40
  reasoning_tokens as reasoningTokens
37
41
  FROM sessions
38
- WHERE input_tokens > 0 OR output_tokens > 0`);
42
+ WHERE input_tokens > 0 OR output_tokens > 0
43
+ OR cache_read_tokens > 0 OR ${cacheWriteColumn} > 0 OR reasoning_tokens > 0`);
39
44
  } catch (err) {
40
45
  if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('Hermes');
41
46
  throw err;
@@ -47,15 +52,19 @@ export async function parse() {
47
52
  if (isNaN(timestamp.getTime())) continue;
48
53
 
49
54
  // Hermes stores input_tokens exclusive of cache (Anthropic-style semantics)
55
+ // and output_tokens inclusive of reasoning (CanonicalUsage.total_tokens
56
+ // adds prompt + output only). Split reasoning instead of counting it twice.
57
+ const output = toCount(row.outputTokens);
58
+ const reasoning = Math.min(output, toCount(row.reasoningTokens));
50
59
  entries.push({
51
60
  source: 'hermes',
52
61
  model: row.model || 'unknown',
53
62
  project: profile,
54
63
  timestamp,
55
- inputTokens: row.inputTokens || 0,
56
- outputTokens: row.outputTokens || 0,
57
- cachedInputTokens: row.cacheReadTokens || 0,
58
- reasoningOutputTokens: row.reasoningTokens || 0,
64
+ inputTokens: toCount(row.inputTokens) + toCount(row.cacheWriteTokens),
65
+ outputTokens: output - reasoning,
66
+ cachedInputTokens: toCount(row.cacheReadTokens),
67
+ reasoningOutputTokens: reasoning,
59
68
  });
60
69
  }
61
70
 
@@ -26,6 +26,7 @@ import { parse as parsePiCodingAgent } from './pi-coding-agent.js';
26
26
  import { parse as parseZcode } from './zcode.js';
27
27
  import { parse as parseTraeCli } from './trae-cli.js';
28
28
  import { parse as parseWorkbuddy } from './workbuddy.js';
29
+ import { parseQoder, parseQoderCn } from './qoder.js';
29
30
 
30
31
  export const parsers = {
31
32
  'claude-code': parseClaudeCode,
@@ -40,6 +41,8 @@ export const parsers = {
40
41
  'openclaw': parseOpenclaw,
41
42
  'omp': parseOmp,
42
43
  'pi-coding-agent': parsePiCodingAgent,
44
+ 'qoder': parseQoder,
45
+ 'qoder-cn': parseQoderCn,
43
46
  'qwen-code': parseQwenCode,
44
47
  'kimi-code': parseKimiCode,
45
48
  'amp': parseAmp,
@@ -0,0 +1,334 @@
1
+ import { createReadStream, existsSync, readdirSync } from 'node:fs';
2
+ import { createInterface } from 'node:readline';
3
+ import { join, basename, extname } from 'node:path';
4
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
5
+ import { projectFromCwd, toCount } from './fs-utils.js';
6
+ import { isSqliteUnavailableError, queryDbJsonSnapshotOnLock, sqliteUnavailableError } from './sqlite.js';
7
+ import { QODER_EDITIONS, getQoderProjectsDir, getQoderDbPath } from '../qoder-roots.js';
8
+
9
+ /**
10
+ * Qoder (Alibaba's agentic coding platform). Two editions with fully separate
11
+ * accounts, billing, model pools and data directories — see ../qoder-roots.js:
12
+ *
13
+ * 'qoder' international qoder.com ~/.qoder Application Support/Qoder
14
+ * 'qoder-cn' China qoder.com.cn ~/.qoder-cn Application Support/QoderCN
15
+ *
16
+ * Each edition has two local data shapes, verified on 2026-09-04 against
17
+ * Qoder CLI 1.1.42, Qoder desktop app 0.1.6 and both IDEs 1.28.0:
18
+ *
19
+ * 1. The IDE's SQLite store <ideDataDir>/SharedClientCache/cache/db/local.db:
20
+ * table chat_message with `token_info` JSON { prompt_tokens, cached_tokens,
21
+ * completion_tokens, max_input_tokens } and `model_info` JSON { model_key }.
22
+ * Real tokens (prompt_tokens INCLUDES cached_tokens), no credits. `model_key`
23
+ * is usually a routing tier ('auto', 'ultimate', 'performance', 'efficient',
24
+ * 'lite') rather than a concrete model; tiers are reported as `qoder-<tier>`
25
+ * so they stay unmatched server-side (see normalizeQoderModel).
26
+ * → token buckets + sessions.
27
+ *
28
+ * 2. JSONL transcripts (CLI + desktop app share them — the app embeds the CLI):
29
+ * <configDir>/projects/<cwd-slug>/<sessionId>.jsonl plus sub-agent files at
30
+ * <configDir>/projects/<cwd-slug>/<sessionId>/subagents/agent-*.jsonl.
31
+ * Claude Code-shaped records { type, timestamp, uuid, sessionId, cwd,
32
+ * message:{ id, role, model, content, usage } }. Qoder bills these in
33
+ * CREDITS: `message.usage` is { input_tokens:0, output_tokens:0, …,
34
+ * credits, original_credits, billable } — every token field is 0. One
35
+ * assistant message is written as several lines (one per content block);
36
+ * only the last line of a message carries `usage`.
37
+ * → sessions only. The credit amount is an account funding path and is not
38
+ * collected (cost-accounting invariant in AGENTS.md); token fields are read
39
+ * so that a future Qoder build that reports tokens is counted without a
40
+ * parser change.
41
+ */
42
+
43
+ const DEFAULT_MODEL = 'qoder-agent';
44
+ const MAX_WARNINGS = 10;
45
+
46
+ // Qoder's routing tiers are not models. Left bare, `auto` collides with the
47
+ // Cursor `auto` entry in the server pricing map and gets billed at Cursor's
48
+ // rate, so tiers are namespaced (`qoder-auto`, …) — those never match a price
49
+ // and render as unmatched, which is the truthful state. Concrete model keys
50
+ // (`qmodel_38max`, …) are passed through unchanged.
51
+ const ROUTING_TIERS = new Set(['auto', 'ultimate', 'performance', 'efficient', 'lite']);
52
+
53
+ function normalizeQoderModel(key) {
54
+ const k = typeof key === 'string' ? key.trim() : '';
55
+ if (!k) return DEFAULT_MODEL;
56
+ return ROUTING_TIERS.has(k.toLowerCase()) ? `qoder-${k.toLowerCase()}` : k;
57
+ }
58
+
59
+ function toDate(value) {
60
+ if (value == null || value === '') return null;
61
+ if (typeof value === 'number') {
62
+ const ms = value < 1e12 ? value * 1000 : value;
63
+ const d = new Date(ms);
64
+ return Number.isNaN(d.getTime()) ? null : d;
65
+ }
66
+ const s = String(value).trim();
67
+ if (/^\d+(\.\d+)?$/.test(s)) return toDate(Number(s));
68
+ const d = new Date(s);
69
+ return Number.isNaN(d.getTime()) ? null : d;
70
+ }
71
+
72
+ function warn(ctx, message) {
73
+ if (ctx.warnings.length < MAX_WARNINGS) ctx.warnings.push(`${ctx.source}: ${message}`);
74
+ }
75
+
76
+ // ── JSONL layer (CLI + desktop app) ────────────────────────────────────────
77
+
78
+ function listJsonlFiles(root, ctx) {
79
+ const out = [];
80
+ const walk = dir => {
81
+ let entries;
82
+ try {
83
+ entries = readdirSync(dir, { withFileTypes: true });
84
+ } catch (err) {
85
+ ctx.skipped = true;
86
+ warn(ctx, `cannot read ${dir}: ${err.message}`);
87
+ return;
88
+ }
89
+ for (const entry of entries) {
90
+ const p = join(dir, entry.name);
91
+ if (entry.isDirectory()) walk(p);
92
+ else if (entry.isFile() && extname(entry.name) === '.jsonl') out.push(p);
93
+ }
94
+ };
95
+ walk(root);
96
+ return out;
97
+ }
98
+
99
+ // A `user` record is a human prompt unless it is a tool result being fed back.
100
+ function isHumanPrompt(record) {
101
+ if (record.humanInput) return true;
102
+ if (record.origin && record.origin.kind === 'human') return true;
103
+ if (record.toolUseResult) return false;
104
+ const content = record.message?.content;
105
+ if (Array.isArray(content)) {
106
+ return !content.some(c => c && typeof c === 'object' && c.type === 'tool_result');
107
+ }
108
+ return true;
109
+ }
110
+
111
+ async function parseTranscriptFile(filePath, ctx) {
112
+ const { source, entries, events } = ctx;
113
+ const fallbackSession = basename(filePath, '.jsonl');
114
+ // One assistant message spans several lines; keep the last usage-bearing
115
+ // record per message id so a call is counted exactly once.
116
+ const usageByMessage = new Map();
117
+
118
+ const rl = createInterface({ input: createReadStream(filePath, { encoding: 'utf-8' }), crlfDelay: Infinity });
119
+ for await (const line of rl) {
120
+ if (!line.trim()) continue;
121
+ let record;
122
+ try {
123
+ record = JSON.parse(line);
124
+ } catch {
125
+ continue;
126
+ }
127
+ if (!record || typeof record !== 'object') continue;
128
+ const type = record.type;
129
+ if (type !== 'user' && type !== 'assistant') continue;
130
+
131
+ const timestamp = toDate(record.timestamp);
132
+ if (!timestamp) continue;
133
+ const sessionId = record.sessionId || record.session_id || fallbackSession;
134
+ const project = projectFromCwd(record.cwd);
135
+ const message = record.message && typeof record.message === 'object' ? record.message : {};
136
+
137
+ if (type === 'user') {
138
+ if (isHumanPrompt(record)) {
139
+ events.push({ sessionId, source, project, timestamp, role: 'user' });
140
+ }
141
+ continue;
142
+ }
143
+
144
+ events.push({ sessionId, source, project, timestamp, role: 'assistant' });
145
+
146
+ const usage = message.usage;
147
+ if (!usage || typeof usage !== 'object') continue;
148
+ const key = `${sessionId}|${message.id || record.uuid || `${filePath}:${usageByMessage.size}`}`;
149
+ usageByMessage.set(key, {
150
+ usage,
151
+ model: normalizeQoderModel(message.model),
152
+ project,
153
+ timestamp,
154
+ });
155
+ }
156
+
157
+ for (const { usage, model, project, timestamp } of usageByMessage.values()) {
158
+ // Cache writes join input (same convention as the Claude Code parser).
159
+ const input = toCount(usage.input_tokens) + toCount(usage.cache_creation_input_tokens);
160
+ const cached = toCount(usage.cache_read_input_tokens ?? usage.cached_tokens);
161
+ const output = toCount(usage.output_tokens);
162
+ if (input + cached + output === 0) continue; // credit-billed call: tokens not reported
163
+ entries.push({
164
+ source,
165
+ model,
166
+ project,
167
+ timestamp,
168
+ inputTokens: input,
169
+ outputTokens: output,
170
+ cachedInputTokens: cached,
171
+ reasoningOutputTokens: 0,
172
+ });
173
+ }
174
+ }
175
+
176
+ async function parseTranscripts(edition, ctx) {
177
+ const root = getQoderProjectsDir(edition);
178
+ if (!existsSync(root)) return;
179
+ for (const file of listJsonlFiles(root, ctx)) {
180
+ try {
181
+ await parseTranscriptFile(file, ctx);
182
+ } catch (err) {
183
+ // A half-written or unreadable transcript: keep prior upload state, retry next run.
184
+ ctx.skipped = true;
185
+ warn(ctx, `cannot read ${file}: ${err.message}`);
186
+ }
187
+ }
188
+ }
189
+
190
+ // ── SQLite layer (IDE) ─────────────────────────────────────────────────────
191
+
192
+ const IDE_COLUMNS = `
193
+ cm.id AS id,
194
+ cm.session_id AS sessionId,
195
+ cm.request_id AS requestId,
196
+ cm.role AS role,
197
+ cm.token_info AS tokenInfo,
198
+ cm.model_info AS modelInfo,
199
+ cm.gmt_create AS created`;
200
+
201
+ // Only token/model/timing columns are selected; message content, tool results
202
+ // and summaries are never read.
203
+ const IDE_QUERY_WITH_SESSION = `SELECT ${IDE_COLUMNS},
204
+ cs.project_uri AS projectUri,
205
+ cs.project_name AS projectName,
206
+ cs.preferred_model_info AS preferredModelInfo
207
+ FROM chat_message cm
208
+ LEFT JOIN chat_session cs ON cs.session_id = cm.session_id
209
+ WHERE cm.role IN ('user', 'assistant')`;
210
+
211
+ const IDE_QUERY_PLAIN = `SELECT ${IDE_COLUMNS}
212
+ FROM chat_message cm
213
+ WHERE cm.role IN ('user', 'assistant')`;
214
+
215
+ function isMissingTable(err, table) {
216
+ return err && typeof err.message === 'string' && new RegExp(`no such table:\\s*${table}`, 'i').test(err.message);
217
+ }
218
+
219
+ function queryIdeRows(dbPath) {
220
+ const opts = { tempPrefix: 'vibe-usage-qoder-' };
221
+ try {
222
+ return queryDbJsonSnapshotOnLock(dbPath, IDE_QUERY_WITH_SESSION, opts);
223
+ } catch (err) {
224
+ // Older Qoder CN builds have no chat_session table; degrade to unattributed projects.
225
+ if (isMissingTable(err, 'chat_session')) return queryDbJsonSnapshotOnLock(dbPath, IDE_QUERY_PLAIN, opts);
226
+ if (isMissingTable(err, 'chat_message')) return [];
227
+ throw err;
228
+ }
229
+ }
230
+
231
+ function parseJson(value) {
232
+ if (!value) return null;
233
+ if (typeof value === 'object') return value;
234
+ try {
235
+ const parsed = JSON.parse(String(value));
236
+ return parsed && typeof parsed === 'object' ? parsed : null;
237
+ } catch {
238
+ return null;
239
+ }
240
+ }
241
+
242
+ function ideProject(row) {
243
+ const uri = typeof row.projectUri === 'string' ? row.projectUri.trim() : '';
244
+ if (uri) {
245
+ if (uri.startsWith('file://')) {
246
+ try {
247
+ return projectFromCwd(decodeURIComponent(new URL(uri).pathname));
248
+ } catch {
249
+ // fall through to project_name
250
+ }
251
+ } else {
252
+ return projectFromCwd(uri);
253
+ }
254
+ }
255
+ const name = typeof row.projectName === 'string' ? row.projectName.trim() : '';
256
+ // '.' is Qoder's "no project" sentinel.
257
+ return name && name !== '.' ? name : 'unknown';
258
+ }
259
+
260
+ function ideModel(row) {
261
+ const info = parseJson(row.modelInfo);
262
+ const preferred = parseJson(row.preferredModelInfo);
263
+ const key = info?.model_key || info?.modelKey || preferred?.model_key || preferred?.modelKey;
264
+ return normalizeQoderModel(key);
265
+ }
266
+
267
+ function parseIde(edition, ctx) {
268
+ const { source, entries, events } = ctx;
269
+ const dbPath = getQoderDbPath(edition);
270
+ if (!existsSync(dbPath)) return;
271
+
272
+ let rows;
273
+ try {
274
+ rows = queryIdeRows(dbPath);
275
+ } catch (err) {
276
+ if (isSqliteUnavailableError(err)) throw sqliteUnavailableError(`${QODER_EDITIONS[edition].label} IDE`);
277
+ // Schema drift or a transient read failure: fail soft so incremental state
278
+ // for this source is not pruned.
279
+ ctx.skipped = true;
280
+ warn(ctx, `cannot read ${dbPath}: ${err.message}`);
281
+ return;
282
+ }
283
+
284
+ for (const row of rows) {
285
+ const timestamp = toDate(row.created);
286
+ if (!timestamp) continue;
287
+ const project = ideProject(row);
288
+ const sessionId = row.sessionId || 'unknown';
289
+ const role = row.role === 'user' ? 'user' : 'assistant';
290
+ events.push({ sessionId, source, project, timestamp, role });
291
+ if (role !== 'assistant') continue;
292
+
293
+ const tokens = parseJson(row.tokenInfo);
294
+ if (!tokens) continue;
295
+ const prompt = toCount(tokens.prompt_tokens);
296
+ const cached = Math.min(prompt, toCount(tokens.cached_tokens));
297
+ const completion = toCount(tokens.completion_tokens);
298
+ if (prompt + completion === 0) continue;
299
+
300
+ entries.push({
301
+ source,
302
+ model: ideModel(row),
303
+ project,
304
+ timestamp,
305
+ // prompt_tokens already includes cached_tokens.
306
+ inputTokens: prompt - cached,
307
+ outputTokens: completion,
308
+ cachedInputTokens: cached,
309
+ reasoningOutputTokens: 0,
310
+ });
311
+ }
312
+ }
313
+
314
+ // ── Entry points ───────────────────────────────────────────────────────────
315
+
316
+ async function parseEdition(edition) {
317
+ const ctx = { source: QODER_EDITIONS[edition].source, entries: [], events: [], warnings: [], skipped: false };
318
+ await parseTranscripts(edition, ctx);
319
+ parseIde(edition, ctx);
320
+ return {
321
+ buckets: aggregateToBuckets(ctx.entries),
322
+ sessions: extractSessions(ctx.events),
323
+ skipped: ctx.skipped,
324
+ warnings: ctx.warnings,
325
+ };
326
+ }
327
+
328
+ export async function parseQoder() {
329
+ return parseEdition('qoder');
330
+ }
331
+
332
+ export async function parseQoderCn() {
333
+ return parseEdition('qoder-cn');
334
+ }
@@ -0,0 +1,82 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+
5
+ // Path resolution for the two Qoder editions (same role as the other *-roots.js
6
+ // modules: shared by tools.js detection and the parser, free of parser imports).
7
+ //
8
+ // edition CLI config dir IDE data dir (macOS)
9
+ // 'qoder' ~/.qoder ~/Library/Application Support/Qoder
10
+ // 'qoder-cn' ~/.qoder-cn ~/Library/Application Support/QoderCN
11
+ //
12
+ // Verified against Qoder CLI 1.1.42 (both editions are one bundle branded via
13
+ // QODERCLI_SITE) and IDE 1.28.0 product.json / extension code on 2026-09-04.
14
+
15
+ const DB_RELATIVE = join('SharedClientCache', 'cache', 'db', 'local.db');
16
+
17
+ export const QODER_EDITIONS = {
18
+ qoder: {
19
+ source: 'qoder',
20
+ label: 'Qoder',
21
+ cliDirName: '.qoder',
22
+ cliEnv: 'QODER_CONFIG_DIR',
23
+ ideDirName: 'Qoder',
24
+ ideHomeEnv: 'QODER_HOME',
25
+ testProjectsEnv: 'VIBE_USAGE_QODER_PROJECTS',
26
+ testDbEnv: 'VIBE_USAGE_QODER_DB',
27
+ },
28
+ 'qoder-cn': {
29
+ source: 'qoder-cn',
30
+ label: 'Qoder CN',
31
+ cliDirName: '.qoder-cn',
32
+ cliEnv: 'QODERCN_CONFIG_DIR',
33
+ ideDirName: 'QoderCN',
34
+ ideHomeEnv: 'QODER_CN_HOME',
35
+ testProjectsEnv: 'VIBE_USAGE_QODER_CN_PROJECTS',
36
+ testDbEnv: 'VIBE_USAGE_QODER_CN_DB',
37
+ },
38
+ };
39
+
40
+ function expandHome(p) {
41
+ if (!p) return p;
42
+ return p.startsWith('~') ? join(homedir(), p.slice(1)) : p;
43
+ }
44
+
45
+ /** CLI/app transcript root: <configDir>/projects. Honors Qoder's own config-dir env. */
46
+ export function getQoderProjectsDir(edition) {
47
+ const e = QODER_EDITIONS[edition];
48
+ const test = process.env[e.testProjectsEnv]?.trim();
49
+ if (test) return expandHome(test);
50
+ const cfg = process.env[e.cliEnv]?.trim();
51
+ const root = cfg ? expandHome(cfg).replace(/[/\\]+$/, '') : join(homedir(), e.cliDirName);
52
+ return join(root, 'projects');
53
+ }
54
+
55
+ /** IDE SQLite store. Honors QODER_HOME / QODER_CN_HOME like Qoder's own language server. */
56
+ export function getQoderDbPath(edition) {
57
+ const e = QODER_EDITIONS[edition];
58
+ const test = process.env[e.testDbEnv]?.trim();
59
+ if (test) return expandHome(test);
60
+ const home = process.env[e.ideHomeEnv]?.trim();
61
+ if (home) return join(expandHome(home), 'cache', 'db', 'local.db');
62
+ let root;
63
+ if (process.platform === 'darwin') {
64
+ root = join(homedir(), 'Library', 'Application Support', e.ideDirName);
65
+ } else if (process.platform === 'win32') {
66
+ const appData = process.env.APPDATA?.trim() || join(homedir(), 'AppData', 'Roaming');
67
+ root = join(appData, e.ideDirName);
68
+ } else {
69
+ const xdg = process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), '.config');
70
+ root = join(xdg, e.ideDirName);
71
+ }
72
+ return join(root, DB_RELATIVE);
73
+ }
74
+
75
+ /**
76
+ * Installed-detection for tools.js. `~/.qoder` alone proves nothing — the IDE
77
+ * also uses it (product.json dataFolderName) for extensions — so look for the
78
+ * transcript directory or the IDE database specifically.
79
+ */
80
+ export function findQoderDataDirs(edition) {
81
+ return [getQoderProjectsDir(edition), getQoderDbPath(edition)].filter(existsSync);
82
+ }
package/src/tools.js CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  import { findClineDataDirs } from './cline-roots.js';
14
14
  import { findCraftDataDirs } from './craft-roots.js';
15
15
  import { findOmpDataDirs, findPiDataDirs } from './pi-roots.js';
16
+ import { findQoderDataDirs, getQoderProjectsDir } from './qoder-roots.js';
16
17
  import { findWorkbuddyDataDirs } from './workbuddy-roots.js';
17
18
 
18
19
  export function getAlmaDbPath(env = process.env, platform = process.platform, home = homedir()) {
@@ -174,8 +175,7 @@ export function getMimocodeDbPath(env = process.env) {
174
175
 
175
176
  export function findAntigravityDataDirs(extraRoots = []) {
176
177
  return [...new Set([
177
- join(homedir(), '.gemini', 'antigravity'),
178
- join(homedir(), '.gemini', 'antigravity-cli'),
178
+ ...antigravityConversationDirs(homedir()).map(dirname),
179
179
  ...extraRoots.flatMap(root => antigravityConversationDirs(root).map(dirname)),
180
180
  ])].filter(existsSync);
181
181
  }
@@ -324,6 +324,20 @@ export const TOOLS = [
324
324
  findPiDataDirs(extraRootList(extraRoots?.['pi-coding-agent']))
325
325
  ),
326
326
  },
327
+ {
328
+ name: 'Qoder',
329
+ id: 'qoder',
330
+ // CLI + desktop app transcripts; the IDE's SharedClientCache/cache/db/local.db
331
+ // is detected too. `~/.qoder` alone is not proof (the IDE stores extensions there).
332
+ dataDir: getQoderProjectsDir('qoder'),
333
+ detectDataDirs: () => findQoderDataDirs('qoder'),
334
+ },
335
+ {
336
+ name: 'Qoder CN',
337
+ id: 'qoder-cn',
338
+ dataDir: getQoderProjectsDir('qoder-cn'),
339
+ detectDataDirs: () => findQoderDataDirs('qoder-cn'),
340
+ },
327
341
  {
328
342
  name: 'Qwen Code',
329
343
  id: 'qwen-code',