@juspay/neurolink 12.7.9 → 12.8.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 +378 -378
- 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/localUsageReaderRegistry.js +15 -0
- package/dist/types/localUsage.d.ts +42 -0
- 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
|
+
}
|
|
@@ -108,3 +108,18 @@ registerLocalUsageReader({
|
|
|
108
108
|
return createCopilotCliReader();
|
|
109
109
|
},
|
|
110
110
|
});
|
|
111
|
+
registerLocalUsageReader({
|
|
112
|
+
descriptor: {
|
|
113
|
+
id: "cursor",
|
|
114
|
+
displayName: "Cursor",
|
|
115
|
+
verified: true,
|
|
116
|
+
dedupStrategy: "last-write-wins",
|
|
117
|
+
costConfidence: "unavailable",
|
|
118
|
+
requiresSqlite: true,
|
|
119
|
+
requestUnit: "session-snapshot",
|
|
120
|
+
},
|
|
121
|
+
factory: async () => {
|
|
122
|
+
const { createCursorReader } = await import("./cursorReader.js");
|
|
123
|
+
return createCursorReader();
|
|
124
|
+
},
|
|
125
|
+
});
|
|
@@ -39,6 +39,21 @@ export type LocalUsageCostConfidence = "modeled" | "unavailable" | "heuristic";
|
|
|
39
39
|
* next reader — the aggregator does not branch on it.
|
|
40
40
|
*/
|
|
41
41
|
export type LocalUsageDedupStrategy = "message-id-keep-max" | "last-write-wins" | "rowid-high-water-mark" | "session-dag";
|
|
42
|
+
/**
|
|
43
|
+
* What one unit of `LocalUsageTotals.requests` actually counts.
|
|
44
|
+
*
|
|
45
|
+
* Not cosmetic. Every reader but one counts assistant turns, so the CLI could
|
|
46
|
+
* safely print "turns" for all of them. Cursor persists no per-turn record at
|
|
47
|
+
* all — only a snapshot of the current context, one per session however many
|
|
48
|
+
* turns that session ran — so printing "turns 1" for a two-hundred-turn
|
|
49
|
+
* session states something false. The unit travels with the number for the
|
|
50
|
+
* same reason `costConfidence` does: a figure rendered without saying what it
|
|
51
|
+
* counts is the failure this whole subsystem exists to avoid.
|
|
52
|
+
*
|
|
53
|
+
* Optional on the descriptor, defaulting to "turn", so adding it does not
|
|
54
|
+
* break an existing caller constructing a descriptor of its own.
|
|
55
|
+
*/
|
|
56
|
+
export type LocalUsageRequestUnit = "turn" | "session-snapshot";
|
|
42
57
|
/** Aggregated totals for one CLI, one scan. */
|
|
43
58
|
export type LocalUsageTotals = {
|
|
44
59
|
requests: number;
|
|
@@ -86,6 +101,11 @@ export type LocalUsageReaderDescriptor = {
|
|
|
86
101
|
costConfidence: LocalUsageCostConfidence;
|
|
87
102
|
/** Whether reading this CLI's store needs a SQLite binding. */
|
|
88
103
|
requiresSqlite: boolean;
|
|
104
|
+
/**
|
|
105
|
+
* What `LocalUsageTotals.requests` counts for this reader. Absent means
|
|
106
|
+
* "turn", which is what every reader except Cursor records.
|
|
107
|
+
*/
|
|
108
|
+
requestUnit?: LocalUsageRequestUnit;
|
|
89
109
|
};
|
|
90
110
|
/** Options accepted by every reader's `scan()` and by the aggregator. */
|
|
91
111
|
export type LocalUsageScanOptions = {
|
|
@@ -230,6 +250,28 @@ export type LocalUsageCopilotUsageRow = {
|
|
|
230
250
|
* runtime rather than trusting a type assertion — naming only what is actually
|
|
231
251
|
* called keeps that check small and honest.
|
|
232
252
|
*/
|
|
253
|
+
/**
|
|
254
|
+
* One decoded protobuf field from a Cursor root blob. Wire types 0 (varint)
|
|
255
|
+
* and 2 (length-delimited) only — the two Cursor actually uses; fixed-width
|
|
256
|
+
* fields are skipped by the decoder rather than represented.
|
|
257
|
+
*/
|
|
258
|
+
export type LocalUsageWireField = {
|
|
259
|
+
field: number;
|
|
260
|
+
kind: "varint";
|
|
261
|
+
value: number;
|
|
262
|
+
} | {
|
|
263
|
+
field: number;
|
|
264
|
+
kind: "bytes";
|
|
265
|
+
value: Uint8Array;
|
|
266
|
+
};
|
|
267
|
+
/**
|
|
268
|
+
* One entry in Cursor's context breakdown — `system_prompt`, `tools`, `rules`
|
|
269
|
+
* and friends — carrying the token count that entry occupies in context.
|
|
270
|
+
*/
|
|
271
|
+
export type LocalUsageContextPart = {
|
|
272
|
+
name: string;
|
|
273
|
+
tokens: number;
|
|
274
|
+
};
|
|
233
275
|
export type LocalUsageSqliteDatabase = {
|
|
234
276
|
prepare: (sql: string) => {
|
|
235
277
|
all: (...params: unknown[]) => unknown[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juspay/neurolink",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.8.0",
|
|
4
4
|
"packageManager": "pnpm@10.15.1",
|
|
5
5
|
"description": "TypeScript AI SDK with 24+ LLM providers behind one consistent API. MCP-native (connect any MCP server), voice TTS/STT/realtime, RAG, agents, memory, context compaction. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · DeepSeek · NVIDIA NIM and more.",
|
|
6
6
|
"author": {
|