@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.
- package/CHANGELOG.md +3 -3
- package/dist/browser/neurolink.min.js +387 -387
- package/dist/cli/commands/usage.js +10 -1
- package/dist/localUsage/cursorReader.d.ts +50 -0
- package/dist/localUsage/cursorReader.js +450 -0
- package/dist/localUsage/grokReader.d.ts +73 -0
- package/dist/localUsage/grokReader.js +325 -0
- package/dist/localUsage/hermesReader.d.ts +51 -0
- package/dist/localUsage/hermesReader.js +350 -0
- package/dist/localUsage/localUsageReaderRegistry.js +43 -0
- package/dist/types/localUsage.d.ts +98 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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>;
|