@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.
@@ -2588,6 +2588,12 @@ async function startProxyRuntime(params) {
2588
2588
  }
2589
2589
  if (result.applied) {
2590
2590
  logger.always(chalk.green(` ✓ Auto-configured ${result.displayName} settings`));
2591
+ if (result.note) {
2592
+ // A written file is not the same as a live configuration. Printing
2593
+ // the check without this reads as "done" for a client that is still
2594
+ // talking to its own upstream.
2595
+ logger.always(chalk.yellow(` ↳ ${result.note}`));
2596
+ }
2591
2597
  logger.always(chalk.dim(` Restart ${result.displayName} to connect through proxy`));
2592
2598
  }
2593
2599
  }
@@ -4042,6 +4048,9 @@ export const proxySetupCommand = {
4042
4048
  }
4043
4049
  if (result.applied) {
4044
4050
  console.info(chalk.green(` ✓ ${result.displayName} configured`));
4051
+ if (result.note) {
4052
+ console.info(chalk.yellow(` ↳ ${result.note}`));
4053
+ }
4045
4054
  }
4046
4055
  }
4047
4056
  // Done!
@@ -75,7 +75,11 @@ export class UsageCommandFactory {
75
75
  // given with no value, which is a mistake and must be rejected — a
76
76
  // truthiness check treats the two as the same and silently scans every
77
77
  // reader instead, reporting everything for a request that named nothing.
78
- const wanted = argv.cli;
78
+ // "copilot-cli" is the pre-rename spelling and is still in the published
79
+ // LocalUsageCliId union, so it must keep resolving. Normalised here rather
80
+ // than registered twice: two descriptors for one reader would show the CLI
81
+ // twice in every report.
82
+ const wanted = argv.cli === "copilot-cli" ? "copilot" : argv.cli;
79
83
  const known = getLocalUsageDescriptors().map((d) => d.id);
80
84
  if (wanted !== undefined &&
81
85
  !known.includes(wanted)) {
@@ -140,6 +144,16 @@ export class UsageCommandFactory {
140
144
  for (const failure of report.failures) {
141
145
  logger.always(chalk.yellow(` ${failure.cliId} failed: ${failure.message}`));
142
146
  }
147
+ // A reader that read nine of ten transcripts still reports totals, and
148
+ // those totals are wrong by the tenth. Printed next to the numbers they
149
+ // undercut, because a silently short total is the failure mode this whole
150
+ // command exists to avoid.
151
+ if (report.scanErrors.length > 0) {
152
+ logger.always(chalk.yellow(` ${report.scanErrors.length} file(s) could not be read; totals are incomplete`));
153
+ for (const scanError of report.scanErrors.slice(0, 5)) {
154
+ logger.always(chalk.dim(` ${scanError.cliId}: ${scanError.message}`));
155
+ }
156
+ }
143
157
  logger.always("");
144
158
  }
145
159
  }
@@ -28,12 +28,15 @@ import type { CliProxyClientConfigurator } from "../../types/index.js";
28
28
  */
29
29
  declare function getCopilotConfigDir(): string;
30
30
  declare function getCopilotEnvPath(): string;
31
+ /** Whether any shell profile already sources the generated script. */
32
+ declare function isEnvScriptSourced(): Promise<boolean>;
31
33
  export declare function setCopilotProxySettings(baseUrl: string, proxyKey?: string): Promise<boolean>;
32
34
  export declare function clearCopilotProxySettings(expectedBaseUrl?: string): Promise<boolean>;
33
35
  export declare const copilotConfigurator: CliProxyClientConfigurator;
34
36
  export declare const __copilotTestHooks: {
35
37
  getCopilotConfigDir: typeof getCopilotConfigDir;
36
38
  getCopilotEnvPath: typeof getCopilotEnvPath;
39
+ isEnvScriptSourced: typeof isEnvScriptSourced;
37
40
  setCopilotProxySettings: typeof setCopilotProxySettings;
38
41
  clearCopilotProxySettings: typeof clearCopilotProxySettings;
39
42
  };
@@ -24,6 +24,7 @@
24
24
  import { homedir } from "os";
25
25
  import { join } from "path";
26
26
  import { logger } from "../../utils/logger.js";
27
+ import { DEFAULT_PROXY_MODEL_ID } from "../../constants/proxyModels.js";
27
28
  import { writeFileAtomic } from "./snapshot.js";
28
29
  /**
29
30
  * Resolved per call rather than at module load so `detect()` and `apply()`
@@ -35,6 +36,31 @@ function getCopilotConfigDir() {
35
36
  function getCopilotEnvPath() {
36
37
  return join(homedir(), ".neurolink", "copilot-env.sh");
37
38
  }
39
+ /**
40
+ * Shell profiles that could plausibly source the script.
41
+ *
42
+ * Checked to answer one question: has the user done the manual half? Writing
43
+ * the file is only half the job, and until the other half happens Copilot
44
+ * talks to GitHub, not the proxy.
45
+ */
46
+ function getProfileCandidates() {
47
+ return [".zshrc", ".zprofile", ".bashrc", ".bash_profile", ".profile"].map((name) => join(homedir(), name));
48
+ }
49
+ /** Whether any shell profile already sources the generated script. */
50
+ async function isEnvScriptSourced() {
51
+ const fs = await import("fs");
52
+ for (const profile of getProfileCandidates()) {
53
+ try {
54
+ if (fs.readFileSync(profile, "utf8").includes("copilot-env.sh")) {
55
+ return true;
56
+ }
57
+ }
58
+ catch {
59
+ // Absent or unreadable profile — not evidence either way, keep looking.
60
+ }
61
+ }
62
+ return false;
63
+ }
38
64
  function buildCopilotEnvScript(baseUrl, proxyKey) {
39
65
  return [
40
66
  "# Generated by `neurolink proxy` — do not edit.",
@@ -46,6 +72,11 @@ function buildCopilotEnvScript(baseUrl, proxyKey) {
46
72
  'export COPILOT_PROVIDER_TYPE="openai"',
47
73
  `export COPILOT_PROVIDER_BASE_URL="${baseUrl}"`,
48
74
  `export COPILOT_PROVIDER_API_KEY="${proxyKey}"`,
75
+ // Without a model id Copilot's BYOK path refuses to start — "BYOK
76
+ // providers require an explicit model" — so a script that stops at the
77
+ // base URL still needs `--model` on every invocation. Export a default and
78
+ // let the user override it after sourcing.
79
+ `export COPILOT_PROVIDER_MODEL_ID="\${COPILOT_PROVIDER_MODEL_ID:-${DEFAULT_PROXY_MODEL_ID}}"`,
49
80
  "",
50
81
  ].join("\n");
51
82
  }
@@ -101,6 +132,13 @@ export async function clearCopilotProxySettings(expectedBaseUrl) {
101
132
  export const copilotConfigurator = {
102
133
  id: "copilot",
103
134
  displayName: "Copilot CLI",
135
+ // Copilot is the only client whose apply() can fully succeed and still have
136
+ // no effect: the script it writes does nothing until a shell profile sources
137
+ // it. Without this note the proxy prints a green check for a file nothing
138
+ // reads, which is indistinguishable from working.
139
+ postApplyNote: async () => (await isEnvScriptSourced())
140
+ ? null
141
+ : "Copilot reads provider settings from the environment only. Add this line to your shell profile, then open a new shell:\n [ -f ~/.neurolink/copilot-env.sh ] && . ~/.neurolink/copilot-env.sh",
104
142
  detect: async () => {
105
143
  const fs = await import("fs");
106
144
  try {
@@ -118,6 +156,7 @@ export const copilotConfigurator = {
118
156
  export const __copilotTestHooks = {
119
157
  getCopilotConfigDir,
120
158
  getCopilotEnvPath,
159
+ isEnvScriptSourced,
121
160
  setCopilotProxySettings,
122
161
  clearCopilotProxySettings,
123
162
  };
@@ -34,7 +34,30 @@ export async function applyAllClients(proxyBaseUrl) {
34
34
  const applied = (await client.detect())
35
35
  ? await client.apply(proxyBaseUrl)
36
36
  : false;
37
- results.push({ id: client.id, displayName: client.displayName, applied });
37
+ // Only ask for a note when something was actually written: a note on a
38
+ // client that was skipped would read as an instruction to act on a
39
+ // configuration that does not exist.
40
+ //
41
+ // Computed in its own try, outside apply()'s. A note is advisory; a
42
+ // throwing implementation must not be able to turn a successful write
43
+ // into `applied: false` and an error the caller reports as a failed
44
+ // configuration. Dormant today only because the one implementation
45
+ // swallows its own errors, which is not a property to rely on.
46
+ let note = null;
47
+ if (applied) {
48
+ try {
49
+ note = (await client.postApplyNote?.(proxyBaseUrl)) ?? null;
50
+ }
51
+ catch (noteError) {
52
+ logger.debug(`[proxy] ${client.id} post-apply note failed: ${noteError instanceof Error ? noteError.message : String(noteError)}`);
53
+ }
54
+ }
55
+ results.push({
56
+ id: client.id,
57
+ displayName: client.displayName,
58
+ applied,
59
+ ...(note === null ? {} : { note }),
60
+ });
38
61
  }
39
62
  catch (error) {
40
63
  const wrapped = error instanceof Error ? error : new Error(String(error));
@@ -20,3 +20,17 @@
20
20
  * better than the empty map it replaces.
21
21
  */
22
22
  export declare const DEFAULT_PROXY_MODEL_IDS: readonly string[];
23
+ /**
24
+ * The model a client is pointed at when it requires one and the user has not
25
+ * chosen.
26
+ *
27
+ * Copilot's BYOK path refuses to start without an explicit model —
28
+ * `copilot -p "..."` fails with "BYOK providers require an explicit model" —
29
+ * so a configurator that writes a base URL and stops has produced something
30
+ * unusable without an extra flag on every invocation.
31
+ *
32
+ * Sonnet rather than Opus or Haiku, matching the reasoning already recorded on
33
+ * DEFAULT_MODELS_BY_TIER's `api` entry in src/lib/models/anthropicModels.ts:
34
+ * the balance most callers want when nobody has expressed a preference.
35
+ */
36
+ export declare const DEFAULT_PROXY_MODEL_ID = "claude-sonnet-4-6";
@@ -34,3 +34,17 @@ export const DEFAULT_PROXY_MODEL_IDS = [
34
34
  "gemini-2.5-pro",
35
35
  "gemini-2.5-flash",
36
36
  ];
37
+ /**
38
+ * The model a client is pointed at when it requires one and the user has not
39
+ * chosen.
40
+ *
41
+ * Copilot's BYOK path refuses to start without an explicit model —
42
+ * `copilot -p "..."` fails with "BYOK providers require an explicit model" —
43
+ * so a configurator that writes a base URL and stops has produced something
44
+ * unusable without an extra flag on every invocation.
45
+ *
46
+ * Sonnet rather than Opus or Haiku, matching the reasoning already recorded on
47
+ * DEFAULT_MODELS_BY_TIER's `api` entry in src/lib/models/anthropicModels.ts:
48
+ * the balance most callers want when nobody has expressed a preference.
49
+ */
50
+ export const DEFAULT_PROXY_MODEL_ID = "claude-sonnet-4-6";
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Reads token usage out of Copilot CLI's local SQLite store.
3
+ *
4
+ * Store: `~/.copilot/session-store.db`. Usage lives on the
5
+ * `assistant_usage_events` table — one INSERT-only row per assistant turn,
6
+ * with `model`, `input_tokens`, `output_tokens`, `cache_read_tokens`,
7
+ * `cache_write_tokens`, `reasoning_tokens` and a `created_at` timestamp
8
+ * (ISO-8601 with a `Z` suffix). Confirmed no duplicate `(session_id,
9
+ * turn_index)` pair exists on a reference machine, so — like OpenCode's
10
+ * `message` table — this needs no dedup logic beyond the table's own
11
+ * uniqueness: every row is counted once, matching the `rowid-high-water-mark`
12
+ * strategy already established for that reader.
13
+ *
14
+ * The same database also holds a JSONL-adjacent `session.shutdown` event with
15
+ * its own `modelMetrics`/`tokenDetails` payload (written to
16
+ * `~/.copilot/session-state/*\/events.jsonl`, not this database). It was
17
+ * deliberately NOT used here: cross-checking real sessions confirmed the two
18
+ * sources report identical totals for the sessions they both cover, so
19
+ * combining them would double-count rather than add coverage. The SQLite
20
+ * table is the sole source; the tradeoff is a disclosed gap for any history
21
+ * that predates the table's introduction.
22
+ *
23
+ * `cache_read_tokens` and `cache_write_tokens` are both SUBSETS of
24
+ * `input_tokens`, not disjoint — confirmed arithmetically against a real
25
+ * `session.shutdown` record (session `c2c38d0b`): `tokenDetails.input.tokenCount
26
+ * = 3`, `cache_write = 24047`, and `usage.inputTokens = 24050` — exactly
27
+ * `3 + 24047`, with nothing left over for a separate additive interpretation.
28
+ * So `inputTokens` here is `input_tokens - cache_read_tokens -
29
+ * cache_write_tokens`, the same style of subtraction `codexReader.ts`,
30
+ * `qwenCodeReader.ts` and `geminiCliReader.ts` make for the same reason: this
31
+ * subsystem's `LocalUsageTotals.inputTokens` + `.cacheReadTokens` must sum to
32
+ * the true prompt size without double-counting. `reasoning_tokens` folds into
33
+ * output — Copilot's own SDK types (`ShutdownModelMetricUsage`) group it
34
+ * alongside `outputTokens` as a further breakdown of output, not a separate
35
+ * accounting bucket.
36
+ *
37
+ * Cost is deliberately `unavailable`. The table's own `request_multiplier`
38
+ * and `total_nano_aiu` columns are Copilot's request-quota accounting, not a
39
+ * USD figure — the SDK's own generated types tag the closest concept,
40
+ * `ShutdownModelMetricUsage.cost`, `@experimental`, and every real sample row
41
+ * on the reference machine had `request_multiplier: 0.0`. Modeling a
42
+ * per-token dollar figure from public list prices would also ignore that
43
+ * Copilot CLI is typically used under a flat-rate Copilot subscription, the
44
+ * same reasoning that keeps Codex's confidence `unavailable`. This holds even
45
+ * when the underlying model is a metered vendor's (e.g. `claude-haiku-4-5` on
46
+ * the reference machine) — Copilot's telemetry layer normalizes across
47
+ * backend vendors into its own accounting convention, and this reader has no
48
+ * way to tell whether a given row was billed by the minute, by the token, or
49
+ * not separately billed at all.
50
+ */
51
+ import type { LocalUsageReader } from "../types/index.js";
52
+ export declare function createCopilotCliReader(): Promise<LocalUsageReader>;
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Reads token usage out of Copilot CLI's local SQLite store.
3
+ *
4
+ * Store: `~/.copilot/session-store.db`. Usage lives on the
5
+ * `assistant_usage_events` table — one INSERT-only row per assistant turn,
6
+ * with `model`, `input_tokens`, `output_tokens`, `cache_read_tokens`,
7
+ * `cache_write_tokens`, `reasoning_tokens` and a `created_at` timestamp
8
+ * (ISO-8601 with a `Z` suffix). Confirmed no duplicate `(session_id,
9
+ * turn_index)` pair exists on a reference machine, so — like OpenCode's
10
+ * `message` table — this needs no dedup logic beyond the table's own
11
+ * uniqueness: every row is counted once, matching the `rowid-high-water-mark`
12
+ * strategy already established for that reader.
13
+ *
14
+ * The same database also holds a JSONL-adjacent `session.shutdown` event with
15
+ * its own `modelMetrics`/`tokenDetails` payload (written to
16
+ * `~/.copilot/session-state/*\/events.jsonl`, not this database). It was
17
+ * deliberately NOT used here: cross-checking real sessions confirmed the two
18
+ * sources report identical totals for the sessions they both cover, so
19
+ * combining them would double-count rather than add coverage. The SQLite
20
+ * table is the sole source; the tradeoff is a disclosed gap for any history
21
+ * that predates the table's introduction.
22
+ *
23
+ * `cache_read_tokens` and `cache_write_tokens` are both SUBSETS of
24
+ * `input_tokens`, not disjoint — confirmed arithmetically against a real
25
+ * `session.shutdown` record (session `c2c38d0b`): `tokenDetails.input.tokenCount
26
+ * = 3`, `cache_write = 24047`, and `usage.inputTokens = 24050` — exactly
27
+ * `3 + 24047`, with nothing left over for a separate additive interpretation.
28
+ * So `inputTokens` here is `input_tokens - cache_read_tokens -
29
+ * cache_write_tokens`, the same style of subtraction `codexReader.ts`,
30
+ * `qwenCodeReader.ts` and `geminiCliReader.ts` make for the same reason: this
31
+ * subsystem's `LocalUsageTotals.inputTokens` + `.cacheReadTokens` must sum to
32
+ * the true prompt size without double-counting. `reasoning_tokens` folds into
33
+ * output — Copilot's own SDK types (`ShutdownModelMetricUsage`) group it
34
+ * alongside `outputTokens` as a further breakdown of output, not a separate
35
+ * accounting bucket.
36
+ *
37
+ * Cost is deliberately `unavailable`. The table's own `request_multiplier`
38
+ * and `total_nano_aiu` columns are Copilot's request-quota accounting, not a
39
+ * USD figure — the SDK's own generated types tag the closest concept,
40
+ * `ShutdownModelMetricUsage.cost`, `@experimental`, and every real sample row
41
+ * on the reference machine had `request_multiplier: 0.0`. Modeling a
42
+ * per-token dollar figure from public list prices would also ignore that
43
+ * Copilot CLI is typically used under a flat-rate Copilot subscription, the
44
+ * same reasoning that keeps Codex's confidence `unavailable`. This holds even
45
+ * when the underlying model is a metered vendor's (e.g. `claude-haiku-4-5` on
46
+ * the reference machine) — Copilot's telemetry layer normalizes across
47
+ * backend vendors into its own accounting convention, and this reader has no
48
+ * way to tell whether a given row was billed by the minute, by the token, or
49
+ * not separately billed at all.
50
+ */
51
+ import { stat } from "fs/promises";
52
+ import { homedir } from "os";
53
+ import { join } from "path";
54
+ import { resolveScanCutoffMs } from "./scanWindow.js";
55
+ const CLI_ID = "copilot";
56
+ function databasePath() {
57
+ return join(homedir(), ".copilot", "session-store.db");
58
+ }
59
+ function emptyTotals() {
60
+ return {
61
+ requests: 0,
62
+ inputTokens: 0,
63
+ outputTokens: 0,
64
+ cacheReadTokens: 0,
65
+ cacheCreationTokens: 0,
66
+ costUsd: 0,
67
+ costConfidence: "unavailable",
68
+ unpricedRequests: 0,
69
+ unpricedModels: [],
70
+ };
71
+ }
72
+ function num(value) {
73
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
74
+ }
75
+ /**
76
+ * `created_at` is an ISO-8601-with-`Z` text column, so the cutoff needs to be
77
+ * the same shape to sort and compare correctly in SQL. `Infinity`/`undefined`
78
+ * (no filter) becomes the epoch, matching `openCodeReader.ts`'s `?? 0`
79
+ * convention of expressing "unbounded" as "since the earliest possible time"
80
+ * rather than leaving the bound parameter out.
81
+ */
82
+ function cutoffIso(sinceDays) {
83
+ const cutoffMs = resolveScanCutoffMs(sinceDays) ?? 0;
84
+ return new Date(cutoffMs).toISOString();
85
+ }
86
+ export async function createCopilotCliReader() {
87
+ return {
88
+ descriptor: {
89
+ id: CLI_ID,
90
+ displayName: "Copilot CLI",
91
+ verified: true,
92
+ dedupStrategy: "rowid-high-water-mark",
93
+ costConfidence: "unavailable",
94
+ requiresSqlite: true,
95
+ },
96
+ detect: async () => {
97
+ try {
98
+ const info = await stat(databasePath());
99
+ return info.isFile();
100
+ }
101
+ catch {
102
+ return false;
103
+ }
104
+ },
105
+ scan: async (options) => {
106
+ const totals = emptyTotals();
107
+ const errors = [];
108
+ const models = new Set();
109
+ const dbPath = databasePath();
110
+ // `node:sqlite` is experimental and may be absent or change shape — the
111
+ // same lazy, validated import `openCodeReader.ts` uses, so one runtime
112
+ // missing it degrades to a reported failure for this reader alone.
113
+ let DatabaseSync;
114
+ try {
115
+ const sqlite = await import("node:sqlite");
116
+ if (typeof sqlite === "object" &&
117
+ sqlite !== null &&
118
+ "DatabaseSync" in sqlite &&
119
+ typeof sqlite.DatabaseSync ===
120
+ "function") {
121
+ DatabaseSync = sqlite.DatabaseSync;
122
+ }
123
+ }
124
+ catch (error) {
125
+ errors.push({
126
+ cliId: CLI_ID,
127
+ filePath: dbPath,
128
+ message: `node:sqlite unavailable on this runtime: ${error instanceof Error ? error.message : String(error)}`,
129
+ });
130
+ return { cliId: CLI_ID, totals, filesScanned: 0, errors };
131
+ }
132
+ if (!DatabaseSync) {
133
+ errors.push({
134
+ cliId: CLI_ID,
135
+ filePath: dbPath,
136
+ message: "node:sqlite did not expose a callable DatabaseSync — the experimental API has likely changed shape",
137
+ });
138
+ return { cliId: CLI_ID, totals, filesScanned: 0, errors };
139
+ }
140
+ const cutoff = cutoffIso(options?.sinceDays);
141
+ let db;
142
+ try {
143
+ // Read-only: this is the user's live store and Copilot CLI may be
144
+ // running. Bound parameter, not interpolation — never splice a
145
+ // caller-influenced value into SQL text.
146
+ db = new DatabaseSync(dbPath, { readOnly: true });
147
+ const rows = db
148
+ .prepare("SELECT model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens, created_at FROM assistant_usage_events WHERE created_at >= ?")
149
+ .all(cutoff);
150
+ for (const row of rows) {
151
+ const inputTokens = num(row.input_tokens);
152
+ const outputTokens = num(row.output_tokens);
153
+ const cacheRead = num(row.cache_read_tokens);
154
+ const cacheWrite = num(row.cache_write_tokens);
155
+ const reasoning = num(row.reasoning_tokens);
156
+ if (inputTokens === 0 &&
157
+ outputTokens === 0 &&
158
+ cacheRead === 0 &&
159
+ cacheWrite === 0 &&
160
+ reasoning === 0) {
161
+ continue;
162
+ }
163
+ totals.requests += 1;
164
+ totals.inputTokens += Math.max(0, inputTokens - cacheRead - cacheWrite);
165
+ totals.outputTokens += outputTokens + reasoning;
166
+ totals.cacheReadTokens += cacheRead;
167
+ totals.cacheCreationTokens += cacheWrite;
168
+ // Every counted row contributes a name. A null model still raised
169
+ // unpricedRequests, so skipping it here produced a report saying N
170
+ // turns went unpriced while naming fewer than N models — the operator
171
+ // is then chasing a gap the report refuses to identify.
172
+ models.add(row.model ?? "unknown");
173
+ }
174
+ }
175
+ catch (error) {
176
+ errors.push({
177
+ cliId: CLI_ID,
178
+ filePath: dbPath,
179
+ message: error instanceof Error ? error.message : String(error),
180
+ });
181
+ }
182
+ finally {
183
+ try {
184
+ db?.close();
185
+ }
186
+ catch {
187
+ // Closing a database that failed to open is not a second failure.
188
+ }
189
+ }
190
+ totals.unpricedRequests = totals.requests;
191
+ totals.unpricedModels = [...models].sort();
192
+ return {
193
+ cliId: CLI_ID,
194
+ totals,
195
+ filesScanned: totals.requests > 0 || errors.length === 0 ? 1 : 0,
196
+ errors,
197
+ };
198
+ },
199
+ };
200
+ }
@@ -0,0 +1,73 @@
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 type { LocalUsageReader } from "../types/index.js";
73
+ export declare function createGeminiCliReader(): Promise<LocalUsageReader>;