@juspay/neurolink 12.5.3 → 12.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Reads token usage out of Gemini CLI's own session transcripts.
3
+ *
4
+ * Layout, confirmed on a real machine (18 files spanning June–August 2026):
5
+ * `~/.gemini/tmp/<projectSlug>/chats/session-<timestamp>-<id>.jsonl`. The
6
+ * sibling `~/.gemini/history/<projectSlug>/` directories are a git-based
7
+ * shadow-history of edited files, not chat logs — no JSONL lives there.
8
+ *
9
+ * Each file is a small append-only patch log rather than one JSON object per
10
+ * line of "the same shape": line 0 is a header (`sessionId`, `projectHash`,
11
+ * `startTime`, `lastUpdated`, `kind`); every line after that is either
12
+ * `{"$set": {...}}` (a partial merge — `messages` on session bootstrap,
13
+ * scalar fields like `lastUpdated`/`summary` afterwards) or a bare message
14
+ * object appended directly. Confirmed on the installed CLI's own bundled
15
+ * source (`@google/gemini-cli` `ChatRecordingService.pushMessage`): a bare
16
+ * append is `appendRecord(msg)` with no wrapper, which is why real transcripts
17
+ * mix both shapes. This reader unwraps `$set.messages[]` (only ever seen once
18
+ * per file, holding the single injected `<session_context>` bootstrap
19
+ * message) and reads bare objects that carry `id`/`type` directly.
20
+ *
21
+ * The installed source also revealed a real double-write race, invisible in
22
+ * this machine's own samples (0 duplicate ids across all 18 files) but
23
+ * reachable on any machine: `recordMessage()` first pushes a `type: "gemini"`
24
+ * message with whatever `tokens` value is already queued (often `null`,
25
+ * before the response's usage metadata has arrived), and
26
+ * `recordMessageTokens()` — called separately once usage arrives — re-pushes
27
+ * the SAME message id with `tokens` now filled in if it finds the last
28
+ * message still token-less. `pushMessage()` unconditionally appends a new
29
+ * line every time it is called, even for an id it has already written. So the
30
+ * same `id` can legitimately appear twice: once without tokens, once with.
31
+ * Dedup here keeps, per id, whichever record has the larger `tokens.total`
32
+ * (a record with no tokens contributes 0), which is correct for both that
33
+ * race and an ordinary resumed-session replay. The map is per-file, mirroring
34
+ * `claudeCodeReader.ts`.
35
+ *
36
+ * `tokens` is `{input, output, cached, thoughts, tool, total}`, and the source
37
+ * (`recordMessageTokens`) maps it straight from the GenAI response's own
38
+ * `usageMetadata` — `input = promptTokenCount`, `output = candidatesTokenCount`,
39
+ * `cached = cachedContentTokenCount`, `thoughts = thoughtsTokenCount`,
40
+ * `tool = toolUsePromptTokenCount`, `total = totalTokenCount`. Real data
41
+ * confirms `total = input + output + thoughts + tool` and that `cached` is a
42
+ * SUBSET of `input`: the one real record with nonzero cache had
43
+ * `input: 12121, cached: 4073`, and `total (12344) = input (12121) + output
44
+ * (1) + thoughts (222)` — cached tokens are not added on top of input, they
45
+ * are already inside it. So `inputTokens` here is `input - cached`, the same
46
+ * subtraction `codexReader.ts` and `qwenCodeReader.ts` make for the same
47
+ * reason: this subsystem's `LocalUsageTotals.inputTokens` +
48
+ * `.cacheReadTokens` must sum to the true prompt size without double-counting.
49
+ * No cache-CREATION concept exists in this API family's usage metadata, so
50
+ * `cacheCreationTokens` stays 0.
51
+ *
52
+ * The write path also nests subagent transcripts one level deeper —
53
+ * `chats/<parentSessionId>/<subagentSessionId>.jsonl`, per the same source —
54
+ * so this reader recurses under `chats/` rather than globbing one level, even
55
+ * though no nested subagent file exists on the machine this was verified
56
+ * against.
57
+ *
58
+ * Cost is deliberately `unavailable`. Gemini CLI supports three genuinely
59
+ * different auth modes with unrelated billing (`gemini-api-key`: metered, and
60
+ * the Flash models specifically have a real free tier; `oauth-personal`: free,
61
+ * rate-limited; `vertex-ai`: billed to a GCP project at negotiated rates), and
62
+ * no session record — not the header, not a message — carries which mode was
63
+ * active when it was written. `settings.json` records only the CURRENT mode,
64
+ * which cannot be projected onto historical sessions. Modeling a per-token
65
+ * dollar figure from the public API price list would misrepresent every
66
+ * free-tier or Vertex-billed session as if it were pay-as-you-go. Concretely,
67
+ * on the reference machine even the majority model logged (`gemini-3.5-flash`,
68
+ * 12 of 14 sampled turns) has no entry in `pricing.ts` at all — only the
69
+ * minority model (`gemini-3-flash-preview`) does — so most real traffic would
70
+ * be unpriced regardless.
71
+ */
72
+ import { createReadStream } from "fs";
73
+ import { createInterface } from "readline";
74
+ import { readdir, stat } from "fs/promises";
75
+ import { homedir } from "os";
76
+ import { join } from "path";
77
+ import { resolveScanCutoffMs } from "./scanWindow.js";
78
+ const CLI_ID = "gemini-cli";
79
+ function tmpRoot() {
80
+ return join(homedir(), ".gemini", "tmp");
81
+ }
82
+ function emptyTotals() {
83
+ return {
84
+ requests: 0,
85
+ inputTokens: 0,
86
+ outputTokens: 0,
87
+ cacheReadTokens: 0,
88
+ cacheCreationTokens: 0,
89
+ costUsd: 0,
90
+ costConfidence: "unavailable",
91
+ unpricedRequests: 0,
92
+ unpricedModels: [],
93
+ };
94
+ }
95
+ function num(value) {
96
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
97
+ }
98
+ /**
99
+ * A scan that cannot read a directory must say so.
100
+ *
101
+ * Swallowing every readdir failure made an unreadable tree indistinguishable
102
+ * from an empty one: the report showed zero usage, no failure entry, and an
103
+ * operator with a permissions problem had nothing to look at. ENOENT stays
104
+ * silent because a missing root legitimately means "nothing recorded yet";
105
+ * anything else is a real failure and is surfaced.
106
+ */
107
+ function isMissing(error) {
108
+ return (typeof error === "object" &&
109
+ error !== null &&
110
+ error.code === "ENOENT");
111
+ }
112
+ /** Every `chats/` directory, one per project, under `~/.gemini/tmp`. */
113
+ async function collectChatsDirs(root, errors) {
114
+ let entries;
115
+ try {
116
+ entries = await readdir(root, { withFileTypes: true });
117
+ }
118
+ catch (error) {
119
+ if (!isMissing(error)) {
120
+ errors.push({
121
+ cliId: CLI_ID,
122
+ filePath: root,
123
+ message: error instanceof Error ? error.message : String(error),
124
+ });
125
+ }
126
+ return [];
127
+ }
128
+ const dirs = [];
129
+ for (const entry of entries) {
130
+ if (!entry.isDirectory()) {
131
+ continue;
132
+ }
133
+ const chatsDir = join(root, entry.name, "chats");
134
+ try {
135
+ const info = await stat(chatsDir);
136
+ if (info.isDirectory()) {
137
+ dirs.push(chatsDir);
138
+ }
139
+ }
140
+ catch {
141
+ // No `chats/` for this project temp dir — nothing recorded yet.
142
+ }
143
+ }
144
+ return dirs;
145
+ }
146
+ /** Every `.jsonl` transcript at any depth under a `chats/` directory. */
147
+ async function collectTranscripts(dir, out, errors) {
148
+ let entries;
149
+ try {
150
+ entries = await readdir(dir, { withFileTypes: true });
151
+ }
152
+ catch (error) {
153
+ if (!isMissing(error)) {
154
+ errors.push({
155
+ cliId: CLI_ID,
156
+ filePath: dir,
157
+ message: error instanceof Error ? error.message : String(error),
158
+ });
159
+ }
160
+ return;
161
+ }
162
+ for (const entry of entries) {
163
+ const full = join(dir, entry.name);
164
+ if (entry.isDirectory()) {
165
+ await collectTranscripts(full, out, errors);
166
+ }
167
+ else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
168
+ out.push(full);
169
+ }
170
+ }
171
+ }
172
+ /** Pull the message-like objects out of one parsed JSONL line, if any. */
173
+ function messagesFromLine(parsed) {
174
+ if (typeof parsed !== "object" || parsed === null) {
175
+ return [];
176
+ }
177
+ const record = parsed;
178
+ // `!== undefined` also admits `$set: null`, and reading `.messages` off it
179
+ // throws. So does a null element surviving into the loop, where `msg.type`
180
+ // is read. Either exception escapes to the per-file catch and discards that
181
+ // whole transcript's totals — one malformed line silently costing a file.
182
+ if (record.$set !== undefined) {
183
+ if (typeof record.$set !== "object" || record.$set === null) {
184
+ return [];
185
+ }
186
+ const messages = record.$set.messages;
187
+ return Array.isArray(messages)
188
+ ? messages.filter((m) => typeof m === "object" && m !== null)
189
+ : [];
190
+ }
191
+ if (typeof record.id === "string" && typeof record.type === "string") {
192
+ return [parsed];
193
+ }
194
+ return [];
195
+ }
196
+ /**
197
+ * Dedup is by message `id`, keeping the larger `tokens.total` seen for that
198
+ * id — see the module header for why the same id can legitimately appear
199
+ * twice. The map is per-file and discarded after, bounding memory regardless
200
+ * of how many files a scan covers.
201
+ */
202
+ async function foldTranscript(filePath, totals, unpriced) {
203
+ const seen = new Map();
204
+ const rl = createInterface({
205
+ input: createReadStream(filePath, { encoding: "utf8" }),
206
+ crlfDelay: Infinity,
207
+ });
208
+ try {
209
+ let lineNo = 0;
210
+ for await (const line of rl) {
211
+ lineNo += 1;
212
+ if (lineNo === 1 || !line || line.charCodeAt(0) !== 123 /* '{' */) {
213
+ // Line 0 is the session header, never a message.
214
+ continue;
215
+ }
216
+ let parsed;
217
+ try {
218
+ parsed = JSON.parse(line);
219
+ }
220
+ catch {
221
+ continue;
222
+ }
223
+ for (const msg of messagesFromLine(parsed)) {
224
+ if (msg.type !== "gemini" || !msg.tokens) {
225
+ continue;
226
+ }
227
+ const id = msg.id;
228
+ if (typeof id !== "string" || id.length === 0) {
229
+ continue;
230
+ }
231
+ const tokens = msg.tokens;
232
+ const input = num(tokens.input);
233
+ const cached = num(tokens.cached);
234
+ const candidate = {
235
+ model: msg.model ?? "unknown",
236
+ input: Math.max(0, input - cached),
237
+ output: num(tokens.output) + num(tokens.thoughts) + num(tokens.tool),
238
+ cached,
239
+ };
240
+ const existing = seen.get(id);
241
+ // Cached tokens are part of the raw prompt, so a record can carry more
242
+ // total usage and still lose on input+output alone — keep-max would then
243
+ // keep the smaller of two duplicates.
244
+ const candidateTotal = candidate.input + candidate.output + candidate.cached;
245
+ const existingTotal = existing
246
+ ? existing.input + existing.output + existing.cached
247
+ : -1;
248
+ if (!existing || candidateTotal > existingTotal) {
249
+ seen.set(id, candidate);
250
+ }
251
+ }
252
+ }
253
+ }
254
+ finally {
255
+ rl.close();
256
+ }
257
+ for (const turn of seen.values()) {
258
+ totals.requests += 1;
259
+ totals.inputTokens += turn.input;
260
+ totals.outputTokens += turn.output;
261
+ totals.cacheReadTokens += turn.cached;
262
+ totals.unpricedRequests += 1;
263
+ unpriced.add(turn.model);
264
+ }
265
+ }
266
+ export async function createGeminiCliReader() {
267
+ return {
268
+ descriptor: {
269
+ id: CLI_ID,
270
+ displayName: "Gemini CLI",
271
+ verified: true,
272
+ dedupStrategy: "message-id-keep-max",
273
+ costConfidence: "unavailable",
274
+ requiresSqlite: false,
275
+ },
276
+ detect: async () => {
277
+ try {
278
+ const info = await stat(tmpRoot());
279
+ return info.isDirectory();
280
+ }
281
+ catch {
282
+ return false;
283
+ }
284
+ },
285
+ scan: async (options) => {
286
+ const totals = emptyTotals();
287
+ const errors = [];
288
+ const unpriced = new Set();
289
+ const chatsDirs = await collectChatsDirs(tmpRoot(), errors);
290
+ const files = [];
291
+ for (const dir of chatsDirs) {
292
+ await collectTranscripts(dir, files, errors);
293
+ }
294
+ const cutoff = resolveScanCutoffMs(options?.sinceDays);
295
+ let filesScanned = 0;
296
+ for (const file of files) {
297
+ try {
298
+ if (cutoff !== undefined) {
299
+ const info = await stat(file);
300
+ if (info.mtimeMs < cutoff) {
301
+ continue;
302
+ }
303
+ }
304
+ await foldTranscript(file, totals, unpriced);
305
+ filesScanned += 1;
306
+ }
307
+ catch (error) {
308
+ errors.push({
309
+ cliId: CLI_ID,
310
+ filePath: file,
311
+ message: error instanceof Error ? error.message : String(error),
312
+ });
313
+ }
314
+ }
315
+ totals.unpricedModels = [...unpriced].sort();
316
+ return { cliId: CLI_ID, totals, filesScanned, errors };
317
+ },
318
+ };
319
+ }
@@ -19,6 +19,7 @@ export async function readAllLocalUsage(options) {
19
19
  const totals = {};
20
20
  const failures = [];
21
21
  const notInstalled = [];
22
+ const scanErrors = [];
22
23
  // Filtered BEFORE construction, not after: `only` decides which stores are
23
24
  // opened at all. Reading all of them and discarding the rest cost 28s for a
24
25
  // single-CLI query that needs 10.
@@ -35,6 +36,7 @@ export async function readAllLocalUsage(options) {
35
36
  }
36
37
  const result = await reader.scan(options);
37
38
  totals[cliId] = result.totals;
39
+ scanErrors.push(...result.errors);
38
40
  }
39
41
  catch (error) {
40
42
  // One reader throwing must not lose the others' results.
@@ -49,5 +51,6 @@ export async function readAllLocalUsage(options) {
49
51
  totals,
50
52
  failures,
51
53
  notInstalled,
54
+ scanErrors,
52
55
  };
53
56
  }
@@ -66,3 +66,45 @@ registerLocalUsageReader({
66
66
  return createOpenCodeReader();
67
67
  },
68
68
  });
69
+ registerLocalUsageReader({
70
+ descriptor: {
71
+ id: "qwen-code",
72
+ displayName: "Qwen Code",
73
+ verified: true,
74
+ dedupStrategy: "message-id-keep-max",
75
+ costConfidence: "unavailable",
76
+ requiresSqlite: false,
77
+ },
78
+ factory: async () => {
79
+ const { createQwenCodeReader } = await import("./qwenCodeReader.js");
80
+ return createQwenCodeReader();
81
+ },
82
+ });
83
+ registerLocalUsageReader({
84
+ descriptor: {
85
+ id: "gemini-cli",
86
+ displayName: "Gemini CLI",
87
+ verified: true,
88
+ dedupStrategy: "message-id-keep-max",
89
+ costConfidence: "unavailable",
90
+ requiresSqlite: false,
91
+ },
92
+ factory: async () => {
93
+ const { createGeminiCliReader } = await import("./geminiCliReader.js");
94
+ return createGeminiCliReader();
95
+ },
96
+ });
97
+ registerLocalUsageReader({
98
+ descriptor: {
99
+ id: "copilot",
100
+ displayName: "Copilot CLI",
101
+ verified: true,
102
+ dedupStrategy: "rowid-high-water-mark",
103
+ costConfidence: "unavailable",
104
+ requiresSqlite: true,
105
+ },
106
+ factory: async () => {
107
+ const { createCopilotCliReader } = await import("./copilotCliReader.js");
108
+ return createCopilotCliReader();
109
+ },
110
+ });
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Reads token usage out of Qwen Code's own session transcripts.
3
+ *
4
+ * Layout, confirmed on a real machine: `~/.qwen/projects/<sanitized-cwd>/chats/
5
+ * <sessionId>.jsonl`, where the sanitized cwd is the absolute working-directory
6
+ * path with every `/` replaced by `-` — the same convention Claude Code uses
7
+ * for its own `~/.claude/projects` layout, which is unsurprising since Qwen
8
+ * Code's chat-recording model traces back to the same lineage as Gemini CLI's.
9
+ * `~/.qwen/projects/<slug>/` also holds a sibling `memory/` directory with a
10
+ * `MEMORY.md` file and no JSONL, so the walk below is scoped to `chats/`
11
+ * specifically rather than the whole project directory.
12
+ *
13
+ * The bundled CLI (`@qwen-code/qwen-code`, installed copy inspected directly)
14
+ * references `parentSessionId` the same way Gemini CLI's engine does for
15
+ * subagent transcripts, so this reader recurses under each project's `chats/`
16
+ * directory at any depth rather than globbing one level — the same lesson
17
+ * `claudeCodeReader.ts` already paid for.
18
+ *
19
+ * Each line is a flat record with `type`, `uuid`, and (for `type: "assistant"`)
20
+ * a `model` and `usageMetadata` object shaped like Google's GenAI
21
+ * `usageMetadata`: `promptTokenCount`, `candidatesTokenCount`,
22
+ * `thoughtsTokenCount`, `totalTokenCount`, `cachedContentTokenCount`. On a real
23
+ * machine, `totalTokenCount` equalled `promptTokenCount + candidatesTokenCount
24
+ * + thoughtsTokenCount` exactly across all 21 real assistant records sampled —
25
+ * so `thoughtsTokenCount` is additive into output, not a separate bucket.
26
+ *
27
+ * `cachedContentTokenCount` is a SUBSET of `promptTokenCount`, not disjoint —
28
+ * confirmed by reading the installed CLI's own converters rather than
29
+ * inferring from samples, because every real sample on this machine had zero
30
+ * cache tokens. `buildAnthropicUsageMetadata` in
31
+ * `chunks/anthropicContentGenerator-*.js` and the OpenAI-compatible converter
32
+ * in `chunks/chunk-*.js` both derive `cachedContentTokenCount` from the
33
+ * upstream response's own cache-read field and leave `promptTokenCount` as the
34
+ * upstream's full prompt count — the cached portion is never added on top. So
35
+ * `inputTokens` here is `prompt - cached`, matching the convention this
36
+ * subsystem's `LocalUsageTotals` type expects (`inputTokens` +
37
+ * `cacheReadTokens` must sum to the true prompt size without double-counting),
38
+ * the same subtraction `codexReader.ts` does for the same reason.
39
+ *
40
+ * Cost is deliberately `unavailable`. Two independent reasons, either one
41
+ * sufficient on its own: (1) no provider identifier is logged alongside the
42
+ * model name, and Qwen Code is pluggable to arbitrary OpenAI- or
43
+ * Anthropic-compatible backends — on the machine this was written against, the
44
+ * model names logged were `claude-sonnet-4-5` and `claude-haiku-4-5`, i.e. this
45
+ * particular install was routed through a custom/litellm endpoint rather than
46
+ * Alibaba's own Qwen models, so even the model name does not reliably identify
47
+ * a vendor to price against; (2) Qwen Code's own hosted models are commonly
48
+ * used via a subscription/OAuth flow rather than metered API keys, the same
49
+ * situation that keeps Codex's confidence `unavailable`.
50
+ */
51
+ import type { LocalUsageReader } from "../types/index.js";
52
+ export declare function createQwenCodeReader(): Promise<LocalUsageReader>;