@juspay/neurolink 12.7.9 → 12.9.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.
@@ -106,13 +106,22 @@ export class UsageCommandFactory {
106
106
  // block of zeros for it buries the rows that matter, so it gets one line.
107
107
  const quiet = rows.filter(([, t]) => t && t.requests === 0);
108
108
  const active = rows.filter(([, t]) => t && t.requests > 0);
109
+ // What `requests` counts is not the same for every reader, so the label is
110
+ // read off the descriptor rather than hard-coded. Printing "turns" for a
111
+ // reader that counts sessions is a small lie that makes a Cursor row look
112
+ // directly comparable to a Claude Code row, which it is not.
113
+ const unitById = new Map(getLocalUsageDescriptors().map((d) => [d.id, d.requestUnit ?? "turn"]));
109
114
  for (const [cliId, totals] of active) {
110
115
  if (!totals) {
111
116
  continue;
112
117
  }
113
118
  const cached = totals.cacheReadTokens + totals.cacheCreationTokens;
114
119
  logger.always(chalk.cyan(` ${cliId}`));
115
- logger.always(` turns ${UsageCommandFactory.formatTokens(totals.requests)}`);
120
+ const unit = unitById.get(cliId) ?? "turn";
121
+ logger.always(unit === "session-snapshot"
122
+ ? ` sessions ${UsageCommandFactory.formatTokens(totals.requests)}` +
123
+ chalk.dim(" (context snapshots, not per-turn usage)")
124
+ : ` turns ${UsageCommandFactory.formatTokens(totals.requests)}`);
116
125
  logger.always(` input ${UsageCommandFactory.formatTokens(totals.inputTokens)}` +
117
126
  ` output ${UsageCommandFactory.formatTokens(totals.outputTokens)}` +
118
127
  ` cached ${UsageCommandFactory.formatTokens(cached)}`);
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Reads token usage out of Cursor CLI's local chat store.
3
+ *
4
+ * Store: `~/.cursor/chats/<workspaceHash>/<agentId>/store.db`, one SQLite file
5
+ * per agent session, with two tables:
6
+ *
7
+ * - `meta` — a single row whose `value` is **hex-encoded** JSON (not JSON,
8
+ * and not a blob: a hex *string*), carrying `latestRootBlobId`, `name`,
9
+ * `createdAt` and `lastUsedModel`.
10
+ * - `blobs` — a content-addressed store, `id` = SHA-256 of `data`. Some
11
+ * blobs are UTF-8 JSON messages; the root blob is **protobuf**.
12
+ *
13
+ * **What this reader reports is NOT cumulative spend, and that difference is
14
+ * the whole reason to read this file before trusting the number.** Every other
15
+ * reader here sums per-turn usage across a transcript. Cursor persists no
16
+ * per-turn usage at all: the only counts in the store are a snapshot of the
17
+ * *current context composition* on the newest root blob — how many tokens the
18
+ * system prompt, tools, rules, skills, subagents and conversation each occupy
19
+ * right now. A session with 200 turns and a session with 1 turn produce one
20
+ * snapshot each. Summing across sessions therefore answers "how much context
21
+ * was loaded, last time each session was touched", never "how much was billed".
22
+ * It lands in `inputTokens` because context is what gets sent as input, and it
23
+ * is the honest home for it — but a dashboard adding this to a metered CLI's
24
+ * input tokens is adding two different quantities. The same hazard OpenCode's
25
+ * reader documents for double-counted proxy traffic, one step further.
26
+ *
27
+ * **Why the parse validates itself.** Protobuf field numbers are an internal
28
+ * detail of a closed-source binary that updates itself, so hard-coding "the
29
+ * total is field 5.1" is a claim about a format nobody published. Instead the
30
+ * parser finds the breakdown by *shape* — submessages of exactly
31
+ * `{1: string, 2: string, 3?: varint, 4?: varint}` — sums their token fields,
32
+ * and then requires that sum to appear verbatim as a varint elsewhere in the
33
+ * same message. On the reference store that is
34
+ * 480 + 11592 + 9257 + 6621 + 391 + 143 = 28484, matching the stored 28484
35
+ * exactly, against a 200000 window. If a future Cursor renumbers its fields the
36
+ * shape match still works; if it changes the *structure*, the sum stops
37
+ * matching and the session is reported as an error rather than counted wrong.
38
+ * A reader that cannot tell "I parsed nothing" from "this session used nothing"
39
+ * is the failure this check exists to prevent.
40
+ *
41
+ * Cost is `unavailable`, not zero-with-confidence: Cursor is a subscription and
42
+ * the store records no model-priced turns to derive a figure from.
43
+ */
44
+ import type { LocalUsageReader } from "../types/index.js";
45
+ /**
46
+ * @returns the session's context-token total, or `null` when the blob holds no
47
+ * breakdown whose entries sum to a total stated alongside them.
48
+ */
49
+ export declare function extractCursorContextTokens(rootBlob: Uint8Array): number | null;
50
+ export declare function createCursorReader(): Promise<LocalUsageReader>;
@@ -0,0 +1,450 @@
1
+ /**
2
+ * Reads token usage out of Cursor CLI's local chat store.
3
+ *
4
+ * Store: `~/.cursor/chats/<workspaceHash>/<agentId>/store.db`, one SQLite file
5
+ * per agent session, with two tables:
6
+ *
7
+ * - `meta` — a single row whose `value` is **hex-encoded** JSON (not JSON,
8
+ * and not a blob: a hex *string*), carrying `latestRootBlobId`, `name`,
9
+ * `createdAt` and `lastUsedModel`.
10
+ * - `blobs` — a content-addressed store, `id` = SHA-256 of `data`. Some
11
+ * blobs are UTF-8 JSON messages; the root blob is **protobuf**.
12
+ *
13
+ * **What this reader reports is NOT cumulative spend, and that difference is
14
+ * the whole reason to read this file before trusting the number.** Every other
15
+ * reader here sums per-turn usage across a transcript. Cursor persists no
16
+ * per-turn usage at all: the only counts in the store are a snapshot of the
17
+ * *current context composition* on the newest root blob — how many tokens the
18
+ * system prompt, tools, rules, skills, subagents and conversation each occupy
19
+ * right now. A session with 200 turns and a session with 1 turn produce one
20
+ * snapshot each. Summing across sessions therefore answers "how much context
21
+ * was loaded, last time each session was touched", never "how much was billed".
22
+ * It lands in `inputTokens` because context is what gets sent as input, and it
23
+ * is the honest home for it — but a dashboard adding this to a metered CLI's
24
+ * input tokens is adding two different quantities. The same hazard OpenCode's
25
+ * reader documents for double-counted proxy traffic, one step further.
26
+ *
27
+ * **Why the parse validates itself.** Protobuf field numbers are an internal
28
+ * detail of a closed-source binary that updates itself, so hard-coding "the
29
+ * total is field 5.1" is a claim about a format nobody published. Instead the
30
+ * parser finds the breakdown by *shape* — submessages of exactly
31
+ * `{1: string, 2: string, 3?: varint, 4?: varint}` — sums their token fields,
32
+ * and then requires that sum to appear verbatim as a varint elsewhere in the
33
+ * same message. On the reference store that is
34
+ * 480 + 11592 + 9257 + 6621 + 391 + 143 = 28484, matching the stored 28484
35
+ * exactly, against a 200000 window. If a future Cursor renumbers its fields the
36
+ * shape match still works; if it changes the *structure*, the sum stops
37
+ * matching and the session is reported as an error rather than counted wrong.
38
+ * A reader that cannot tell "I parsed nothing" from "this session used nothing"
39
+ * is the failure this check exists to prevent.
40
+ *
41
+ * Cost is `unavailable`, not zero-with-confidence: Cursor is a subscription and
42
+ * the store records no model-priced turns to derive a figure from.
43
+ */
44
+ import { readdir, stat } from "fs/promises";
45
+ import { homedir } from "os";
46
+ import { join } from "path";
47
+ import { resolveScanCutoffMs } from "./scanWindow.js";
48
+ const CLI_ID = "cursor";
49
+ function chatsRoot() {
50
+ return join(homedir(), ".cursor", "chats");
51
+ }
52
+ function emptyTotals() {
53
+ return {
54
+ requests: 0,
55
+ inputTokens: 0,
56
+ outputTokens: 0,
57
+ cacheReadTokens: 0,
58
+ cacheCreationTokens: 0,
59
+ costUsd: 0,
60
+ costConfidence: "unavailable",
61
+ unpricedRequests: 0,
62
+ unpricedModels: [],
63
+ };
64
+ }
65
+ /**
66
+ * Decode one protobuf message into its top-level fields.
67
+ *
68
+ * Returns `null` rather than throwing on anything malformed — this walks blobs
69
+ * from a third-party binary, so "not the shape I expected" is an ordinary
70
+ * outcome and must not abort a scan of the other sessions.
71
+ */
72
+ function decodeMessage(buf) {
73
+ const out = [];
74
+ let i = 0;
75
+ const varint = () => {
76
+ let result = 0;
77
+ let shift = 0;
78
+ while (i < buf.length) {
79
+ // Read then advance, without a non-null assertion: the loop condition
80
+ // makes this in-bounds, but an assertion would keep being true if the
81
+ // condition ever changed, and this parser's whole job is to distrust the
82
+ // bytes it is handed.
83
+ const byte = buf[i];
84
+ i += 1;
85
+ if (byte === undefined) {
86
+ return null;
87
+ }
88
+ // Beyond 2^53 a JS number stops being exact, and a token count that
89
+ // silently loses precision is worse than a refused parse.
90
+ if (shift > 53) {
91
+ return null;
92
+ }
93
+ result += (byte & 0x7f) * Math.pow(2, shift);
94
+ shift += 7;
95
+ if ((byte & 0x80) === 0) {
96
+ return result;
97
+ }
98
+ }
99
+ return null;
100
+ };
101
+ while (i < buf.length) {
102
+ const key = varint();
103
+ if (key === null) {
104
+ return null;
105
+ }
106
+ const field = Math.floor(key / 8);
107
+ const wireType = key % 8;
108
+ if (wireType === 0) {
109
+ const value = varint();
110
+ if (value === null) {
111
+ return null;
112
+ }
113
+ out.push({ field, kind: "varint", value });
114
+ }
115
+ else if (wireType === 2) {
116
+ const length = varint();
117
+ if (length === null || i + length > buf.length) {
118
+ return null;
119
+ }
120
+ out.push({ field, kind: "bytes", value: buf.subarray(i, i + length) });
121
+ i += length;
122
+ }
123
+ else if (wireType === 5) {
124
+ i += 4;
125
+ }
126
+ else if (wireType === 1) {
127
+ i += 8;
128
+ }
129
+ else {
130
+ // Groups (3/4) are deprecated and absent here; anything else is garbage.
131
+ return null;
132
+ }
133
+ if (i > buf.length) {
134
+ return null;
135
+ }
136
+ }
137
+ return out;
138
+ }
139
+ const utf8 = new TextDecoder("utf-8", { fatal: true });
140
+ /**
141
+ * Match one breakdown entry by shape.
142
+ *
143
+ * Deliberately strict — every field must be one of the four expected numbers
144
+ * with the expected wire type, and fields 1 and 2 must be valid UTF-8. A loose
145
+ * matcher would collect unrelated submessages and inflate the sum, and the sum
146
+ * is the only thing proving the parse found the right structure at all.
147
+ */
148
+ function tryParsePart(buf) {
149
+ const fields = decodeMessage(buf);
150
+ if (fields === null || fields.length === 0) {
151
+ return null;
152
+ }
153
+ let name;
154
+ let sawLabel = false;
155
+ let tokens = 0;
156
+ for (const f of fields) {
157
+ if (f.field === 1 && f.kind === "bytes") {
158
+ try {
159
+ name = utf8.decode(f.value);
160
+ }
161
+ catch {
162
+ return null;
163
+ }
164
+ }
165
+ else if (f.field === 2 && f.kind === "bytes") {
166
+ try {
167
+ utf8.decode(f.value);
168
+ sawLabel = true;
169
+ }
170
+ catch {
171
+ return null;
172
+ }
173
+ }
174
+ else if (f.field === 3 && f.kind === "varint") {
175
+ tokens = f.value;
176
+ }
177
+ else if (f.field === 4 && f.kind === "varint") {
178
+ // Character count; read but not reported — it is the denominator behind
179
+ // the ~4.08 chars/token ratio, not a usage figure.
180
+ }
181
+ else {
182
+ return null;
183
+ }
184
+ }
185
+ if (name === undefined || !sawLabel) {
186
+ return null;
187
+ }
188
+ return { name, tokens };
189
+ }
190
+ /**
191
+ * Find a breakdown CONTAINER: one message that both holds the entries and
192
+ * states their total.
193
+ *
194
+ * The first version of this searched the whole tree — every shape-matching
195
+ * submessage anywhere became a "part", and the total merely had to appear as
196
+ * some varint somewhere. Both halves were too loose, and the real data proves
197
+ * it rather than hypothesis: this machine's own root blob carries a field 21
198
+ * of `{1: "/Users/…/feat/support-for-ide", 2: "feat/support-for-ide"}`, which
199
+ * is a workspace descriptor and matches the entry shape exactly. Today it is
200
+ * harmless only because it has no field 3, so it contributes 0. Give it one —
201
+ * an entirely ordinary thing for a protobuf message to grow — and the sum
202
+ * inflates; and because a 643-byte blob already holds seventeen distinct
203
+ * varints, "does the sum equal ANY varint in the tree" is a coincidence
204
+ * waiting to be satisfied. The result would be a wrong number that had passed
205
+ * its own validation, which is worse than no number at all.
206
+ *
207
+ * Scoping to a container closes both halves. The entries must be siblings
208
+ * inside one message, and that same message must carry their sum as one of its
209
+ * own varints — which is exactly how Cursor lays it out: the inner message
210
+ * holds the total at field 1, the window at field 2, and the entries as a
211
+ * repeated field 3. A lone shape-match like field 21 can never qualify: it has
212
+ * no varint fields at all, and no entry-shaped children.
213
+ *
214
+ * @returns the container's stated total, or `null` if no message in the tree
215
+ * both contains at least two entries and states their sum.
216
+ */
217
+ function findBreakdownTotal(buf, depth) {
218
+ if (depth > 12) {
219
+ return null;
220
+ }
221
+ const fields = decodeMessage(buf);
222
+ if (fields === null) {
223
+ return null;
224
+ }
225
+ const parts = [];
226
+ for (const f of fields) {
227
+ if (f.kind === "bytes") {
228
+ const part = tryParsePart(f.value);
229
+ if (part !== null) {
230
+ parts.push(part);
231
+ }
232
+ }
233
+ }
234
+ // Two, not one: a single match is far likelier to be an unrelated two-string
235
+ // message than a context breakdown. Safe because Cursor writes its breakdown
236
+ // as a FIXED set — system_prompt, tools, rules, skills, mcp, subagents,
237
+ // summarized_conversation, conversation — all eight present even when a
238
+ // category is empty (mcp and summarized_conversation carried no token field
239
+ // at all on the reference blob). So the count never drops toward one as a
240
+ // session shrinks; a one-entry container would mean the format changed, and
241
+ // that is a case to refuse rather than guess at.
242
+ if (parts.length >= 2) {
243
+ const sum = parts.reduce((acc, p) => acc + p.tokens, 0);
244
+ if (sum > 0) {
245
+ for (const f of fields) {
246
+ if (f.kind === "varint" && f.value === sum) {
247
+ return sum;
248
+ }
249
+ }
250
+ }
251
+ }
252
+ // Not this message — recurse. Depth-first, so a nested container is still
253
+ // found when its parent also holds shape-matching noise.
254
+ for (const f of fields) {
255
+ if (f.kind !== "bytes") {
256
+ continue;
257
+ }
258
+ const found = findBreakdownTotal(f.value, depth + 1);
259
+ if (found !== null) {
260
+ return found;
261
+ }
262
+ }
263
+ return null;
264
+ }
265
+ /**
266
+ * @returns the session's context-token total, or `null` when the blob holds no
267
+ * breakdown whose entries sum to a total stated alongside them.
268
+ */
269
+ export function extractCursorContextTokens(rootBlob) {
270
+ return findBreakdownTotal(rootBlob, 0);
271
+ }
272
+ /** `~/.cursor/chats/<workspaceHash>/<agentId>/store.db`, two levels down. */
273
+ async function findStoreDatabases() {
274
+ const root = chatsRoot();
275
+ const found = [];
276
+ let workspaces;
277
+ try {
278
+ workspaces = await readdir(root);
279
+ }
280
+ catch {
281
+ return found;
282
+ }
283
+ for (const workspace of workspaces) {
284
+ let agents;
285
+ try {
286
+ agents = await readdir(join(root, workspace));
287
+ }
288
+ catch {
289
+ continue;
290
+ }
291
+ for (const agent of agents) {
292
+ const dbPath = join(root, workspace, agent, "store.db");
293
+ try {
294
+ const info = await stat(dbPath);
295
+ if (info.isFile()) {
296
+ found.push(dbPath);
297
+ }
298
+ }
299
+ catch {
300
+ // Not every directory is an agent session.
301
+ }
302
+ }
303
+ }
304
+ return found;
305
+ }
306
+ export async function createCursorReader() {
307
+ return {
308
+ descriptor: {
309
+ id: CLI_ID,
310
+ displayName: "Cursor",
311
+ verified: true,
312
+ // Only the newest root blob per session carries a breakdown; earlier
313
+ // roots in the content-addressed chain hold none. Taking the latest is
314
+ // not a choice between duplicates, it is the only record that exists.
315
+ dedupStrategy: "last-write-wins",
316
+ costConfidence: "unavailable",
317
+ requiresSqlite: true,
318
+ requestUnit: "session-snapshot",
319
+ },
320
+ detect: async () => {
321
+ try {
322
+ const info = await stat(chatsRoot());
323
+ return info.isDirectory();
324
+ }
325
+ catch {
326
+ return false;
327
+ }
328
+ },
329
+ scan: async (options) => {
330
+ const totals = emptyTotals();
331
+ const errors = [];
332
+ let DatabaseSync;
333
+ try {
334
+ const sqlite = await import("node:sqlite");
335
+ if (typeof sqlite === "object" &&
336
+ sqlite !== null &&
337
+ "DatabaseSync" in sqlite &&
338
+ typeof sqlite.DatabaseSync ===
339
+ "function") {
340
+ DatabaseSync = sqlite.DatabaseSync;
341
+ }
342
+ }
343
+ catch (error) {
344
+ errors.push({
345
+ cliId: CLI_ID,
346
+ filePath: chatsRoot(),
347
+ message: `node:sqlite unavailable on this runtime: ${error instanceof Error ? error.message : String(error)}`,
348
+ });
349
+ return { cliId: CLI_ID, totals, filesScanned: 0, errors };
350
+ }
351
+ if (!DatabaseSync) {
352
+ errors.push({
353
+ cliId: CLI_ID,
354
+ filePath: chatsRoot(),
355
+ message: "node:sqlite did not expose a callable DatabaseSync — the experimental API has likely changed shape",
356
+ });
357
+ return { cliId: CLI_ID, totals, filesScanned: 0, errors };
358
+ }
359
+ const cutoffMs = resolveScanCutoffMs(options?.sinceDays);
360
+ let filesScanned = 0;
361
+ for (const dbPath of await findStoreDatabases()) {
362
+ // Filter on the file, before opening it. Unlike OpenCode there is no
363
+ // per-row timestamp to filter in SQL: one database IS one session.
364
+ if (cutoffMs !== undefined) {
365
+ try {
366
+ const info = await stat(dbPath);
367
+ if (info.mtimeMs < cutoffMs) {
368
+ continue;
369
+ }
370
+ }
371
+ catch {
372
+ continue;
373
+ }
374
+ }
375
+ let db;
376
+ try {
377
+ // Read-only: Cursor may be running against this very file.
378
+ db = new DatabaseSync(dbPath, { readOnly: true });
379
+ filesScanned += 1;
380
+ const metaRows = db.prepare("SELECT value FROM meta").all();
381
+ const rawMeta = metaRows.find((row) => typeof row.value === "string")?.value;
382
+ if (rawMeta === undefined) {
383
+ continue;
384
+ }
385
+ // Hex-encoded JSON. Validated rather than assumed: a non-hex value
386
+ // decodes to mojibake and would throw inside JSON.parse anyway, but
387
+ // the explicit test says why the buffer conversion is there.
388
+ if (!/^(?:[0-9a-fA-F]{2})+$/.test(rawMeta)) {
389
+ continue;
390
+ }
391
+ let latestRootBlobId;
392
+ try {
393
+ const meta = JSON.parse(Buffer.from(rawMeta, "hex").toString("utf8"));
394
+ if (typeof meta === "object" &&
395
+ meta !== null &&
396
+ "latestRootBlobId" in meta &&
397
+ typeof meta
398
+ .latestRootBlobId === "string") {
399
+ latestRootBlobId = meta
400
+ .latestRootBlobId;
401
+ }
402
+ }
403
+ catch {
404
+ continue;
405
+ }
406
+ if (latestRootBlobId === undefined) {
407
+ continue;
408
+ }
409
+ const blobRows = db
410
+ .prepare("SELECT data FROM blobs WHERE id = ?")
411
+ .all(latestRootBlobId);
412
+ const blob = blobRows[0]?.data;
413
+ if (!(blob instanceof Uint8Array) || blob.length === 0) {
414
+ continue;
415
+ }
416
+ const contextTokens = extractCursorContextTokens(blob);
417
+ if (contextTokens === null) {
418
+ // Reported, never silently skipped: "no breakdown found" and "this
419
+ // session used nothing" are different facts and must not collapse.
420
+ errors.push({
421
+ cliId: CLI_ID,
422
+ filePath: dbPath,
423
+ message: "root blob carried no context breakdown whose parts sum to a stated total — Cursor's on-disk format has likely changed",
424
+ });
425
+ continue;
426
+ }
427
+ // One snapshot per session, not one per turn. See this module's note.
428
+ totals.requests += 1;
429
+ totals.inputTokens += contextTokens;
430
+ }
431
+ catch (error) {
432
+ errors.push({
433
+ cliId: CLI_ID,
434
+ filePath: dbPath,
435
+ message: error instanceof Error ? error.message : String(error),
436
+ });
437
+ }
438
+ finally {
439
+ try {
440
+ db?.close();
441
+ }
442
+ catch {
443
+ // Closing a database that failed to open is not a second failure.
444
+ }
445
+ }
446
+ }
447
+ return { cliId: CLI_ID, totals, filesScanned, errors };
448
+ },
449
+ };
450
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Reads token usage out of Grok Build's own session streams.
3
+ *
4
+ * Grok Build is xAI's official terminal coding agent (github.com/xai-org/
5
+ * grok-build, Rust, installed from x.ai/cli/install.sh as `grok`). An earlier
6
+ * note in this folder said "grok" named no product, only eight competing npm
7
+ * packages; it had searched npm for a Rust binary that is not distributed
8
+ * there, and is retracted here.
9
+ *
10
+ * Layout, confirmed by running the real binary against a redirected home:
11
+ * `$GROK_HOME/sessions/<url-encoded cwd>/<session-id>/`, defaulting to
12
+ * `~/.grok`. Each session directory holds `updates.jsonl` — the CLI's own
13
+ * documentation calls it the authoritative conversation log — plus
14
+ * `summary.json`, `chat_history.jsonl`, `signals.json` and others. The walk
15
+ * here reads exactly `<group>/<session>/updates.jsonl` and nothing deeper:
16
+ * a session directory can also hold `compaction_checkpoints/`, and a
17
+ * checkpoint that snapshots the stream would otherwise be counted twice.
18
+ *
19
+ * Each line is a JSON-RPC style record. The ones that matter are
20
+ * `method: "_x.ai/session/update"` with `params.update.sessionUpdate ===
21
+ * "turn_completed"`; a completed turn that reached a model carries a `usage`
22
+ * object with `inputTokens`, `outputTokens`, `cachedReadTokens`,
23
+ * `cacheCreationTokens`, `reasoningTokens`, `modelCalls`, a per-model
24
+ * `modelUsage` map and `numTurns`. A turn that failed before any model call
25
+ * carries no `usage` at all, so it excludes itself. `stop_reason` is not an
26
+ * eligibility test: a truncated or interrupted turn with a usage object was
27
+ * still billed.
28
+ *
29
+ * What the numbers MEAN took a second real turn to settle, and the answer is
30
+ * neither "per turn" nor "cumulative" but both, by process:
31
+ *
32
+ * turn 1 (fresh process) inputTokens 10141 numTurns 1
33
+ * turn 2 (`grok -r`, new one) inputTokens 11034 numTurns 1
34
+ *
35
+ * The second record is not 21175, so the usage is not a session-lifetime
36
+ * counter. But the CLI's own persistence code says the figure is the
37
+ * process's live ledger, and computes a turn's cost as live minus the
38
+ * previous live value when the ledger has only grown — a running total within
39
+ * one process, reset when a new process resumes the session. A reader has no
40
+ * process boundary to look at; what it has is `numTurns`, the ledger's own
41
+ * turn counter. Strictly increasing, with every bucket at least as large,
42
+ * means the same ledger, and the turn's usage is the difference from the
43
+ * previous record. Anything else means a fresh ledger, and the whole record
44
+ * counts. Both real records above have numTurns 1, so both count whole, which
45
+ * is the measured truth. The in-process cumulative branch is taken from the
46
+ * CLI's source, not from a measurement — headless `-p` runs are one prompt
47
+ * per process, so no real stream on this machine exercises it.
48
+ *
49
+ * Duplicates: a prompt's terminal record can be re-emitted (the persistence
50
+ * layer folds a late re-emission into the same turn). Dedup is by
51
+ * `prompt_id`, scoped to the session directory — prompt ids are not
52
+ * documented as globally unique — keeping the last record seen. A record
53
+ * with no prompt id is keyed by its line.
54
+ *
55
+ * Cost is `unavailable`: the turn record carries no price, and Grok Build is
56
+ * a subscription product whose custom-model configurations can point at any
57
+ * OpenAI- or Anthropic-compatible endpoint — the real run here used DeepSeek.
58
+ * Cache and reasoning counts are reported as stored; every real sample so far
59
+ * has them at zero, so whether `cachedReadTokens` is a subset of
60
+ * `inputTokens` has not been measured and nothing is subtracted or folded
61
+ * until it has. Reasoning tokens in particular are NOT added to output: on the
62
+ * OpenAI wire they are a subset of completion tokens, and adding them would
63
+ * double count.
64
+ *
65
+ * Not read: the newer per-session `usage.json` the CLI writes from the same
66
+ * ledger. No real run on this machine produced one — the 1.0.13 binary's
67
+ * `grok usage` reports "No usage recorded" for these sessions — and a reader
68
+ * written against a struct definition alone is the guessed-format failure
69
+ * this folder keeps paying for. When a real file exists it belongs here as a
70
+ * fallback for a session with no readable stream, never as an addition.
71
+ */
72
+ import type { LocalUsageReader } from "../types/index.js";
73
+ export declare function createGrokReader(): Promise<LocalUsageReader>;