@vibe-cafe/vibe-usage 0.10.22 → 0.10.24

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
@@ -80,6 +80,8 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
80
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.10.22",
3
+ "version": "0.10.24",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "scripts": {
package/src/api.js CHANGED
@@ -280,6 +280,39 @@ export function getJson(apiUrl, apiKey, path, { timeoutMs = 15_000 } = {}) {
280
280
  });
281
281
  }
282
282
 
283
+ /**
284
+ * Which vibecafe account this API key is bound to.
285
+ *
286
+ * Exists because a key minted against the wrong account is otherwise invisible
287
+ * from the client side: ingest and this endpoint both answer 200 for it, so the
288
+ * user sees a healthy CLI and a healthy desktop app while their uploads pile up
289
+ * on an account they never look at. Ask the backend whose data it is and say so.
290
+ *
291
+ * Returns null when the backend is too old to send `account`, or on any
292
+ * transient failure — identity is a nicety, never a reason to fail a command.
293
+ * UNAUTHORIZED still propagates: that one the caller must not swallow.
294
+ *
295
+ * @param {string} apiUrl
296
+ * @param {string} apiKey
297
+ * @returns {Promise<{handle: string, name: string | null} | null>}
298
+ */
299
+ export async function fetchAccount(apiUrl, apiKey) {
300
+ let data;
301
+ try {
302
+ // bucketsOnly keeps the response to the aggregate we throw away; days=1
303
+ // keeps the window (and the scan) small.
304
+ data = await getJson(apiUrl, apiKey, '/api/usage?days=1&bucketsOnly=true', { timeoutMs: 10_000 });
305
+ } catch (err) {
306
+ if (err.message === 'UNAUTHORIZED') throw err;
307
+ return null;
308
+ }
309
+ const account = data?.account;
310
+ if (account && typeof account.handle === 'string') {
311
+ return { handle: account.handle, name: account.name ?? null };
312
+ }
313
+ return null;
314
+ }
315
+
283
316
  /**
284
317
  * GET user settings from the vibecafe API.
285
318
  * Returns null after transient failures are exhausted. A 401 remains distinct
package/src/index.js CHANGED
@@ -8,7 +8,8 @@ import {
8
8
  normalizeExtraRoot,
9
9
  validateExtraRoot,
10
10
  } from './extra-roots.js';
11
- import { failure, smallHeader } from './output.js';
11
+ import { dim as dimText, failure, smallHeader, warn } from './output.js';
12
+ import { fetchAccount } from './api.js';
12
13
 
13
14
  function printSmallHeader() {
14
15
  console.log();
@@ -27,6 +28,8 @@ async function showStatus() {
27
28
  console.log(` Config: ${getConfigPath()}`);
28
29
  console.log(` API key: ${config.apiKey.slice(0, 8)}...`);
29
30
  console.log(` API URL: ${config.apiUrl || 'https://vibecafe.ai'}`);
31
+ // 数据算在谁名下,是这里最该回答、以前偏偏答不出的一件事。
32
+ await printBoundAccount(config.apiUrl || 'https://vibecafe.ai', config.apiKey);
30
33
  if (config.codexExtraHome) {
31
34
  console.log(` Extra Codex Home: ${config.codexExtraHome}`);
32
35
  }
@@ -62,6 +65,30 @@ async function showStatus() {
62
65
  console.log();
63
66
  }
64
67
 
68
+ /**
69
+ * Print the account this key uploads to. Never throws: an offline machine or an
70
+ * older backend just means we cannot name the account, which must not make
71
+ * `status` fail — but a revoked/invalid key is worth saying out loud.
72
+ */
73
+ async function printBoundAccount(apiUrl, apiKey) {
74
+ try {
75
+ const account = await fetchAccount(apiUrl, apiKey);
76
+ if (account) {
77
+ const label = account.name ? `@${account.handle}(${account.name})` : `@${account.handle}`;
78
+ console.log(` 账号: ${label}`);
79
+ console.log(dimText(` 数据都记在这个账号名下,不是它就换个账号重新 init`));
80
+ } else {
81
+ console.log(dimText(' 账号: 服务端未返回(后端版本较旧或网络异常)'));
82
+ }
83
+ } catch (err) {
84
+ if (err.message === 'UNAUTHORIZED') {
85
+ console.log(warn('账号: Key 已失效,请重新运行 `npx @vibe-cafe/vibe-usage init`'));
86
+ return;
87
+ }
88
+ console.log(dimText(' 账号: 读取失败'));
89
+ }
90
+ }
91
+
65
92
  const VALID_CONFIG_KEYS = ['apiKey', 'apiUrl', 'hostname', 'codexExtraHome'];
66
93
 
67
94
  function handleConfig(args) {
package/src/init.js CHANGED
@@ -2,7 +2,7 @@ import { createInterface } from 'node:readline';
2
2
  import { execFile } from 'node:child_process';
3
3
  import { hostname as osHostname, platform } from 'node:os';
4
4
  import { loadConfig, saveConfig } from './config.js';
5
- import { ingest, requestDeviceCode, pollDeviceCode } from './api.js';
5
+ import { fetchAccount, ingest, requestDeviceCode, pollDeviceCode } from './api.js';
6
6
  import { runSync } from './sync.js';
7
7
  import { detectInstalledTools } from './tools.js';
8
8
  import { bigHeader, success, failure, warn, arrow, link, dim, divider } from './output.js';
@@ -68,6 +68,13 @@ export async function runInit(options = {}) {
68
68
  try {
69
69
  await ingest(apiUrl, apiKey, []);
70
70
  console.log(success(`验证通过 ${dim(apiKey.slice(0, 12) + '...')}`));
71
+ // 说清楚数据会算在谁名下 —— 授权那一刻浏览器里登录的可能并不是他自己以为的
72
+ // 那个账号,而这是整条链路上最后一次能当场发现的机会。
73
+ const account = await fetchAccount(apiUrl, apiKey).catch(() => null);
74
+ if (account) {
75
+ console.log(success(`已链接到账号 ${account.name ? `@${account.handle}(${account.name})` : `@${account.handle}`}`));
76
+ console.log(dim(' 数据都会记在这个账号名下;不是你要的账号就退出登录后重新 init。'));
77
+ }
71
78
  } catch (err) {
72
79
  if (err.message === 'UNAUTHORIZED') {
73
80
  console.error(failure('API Key 无效,请检查后重试。'));
@@ -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()) {
@@ -323,6 +324,20 @@ export const TOOLS = [
323
324
  findPiDataDirs(extraRootList(extraRoots?.['pi-coding-agent']))
324
325
  ),
325
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
+ },
326
341
  {
327
342
  name: 'Qwen Code',
328
343
  id: 'qwen-code',