@juspay/neurolink 12.7.8 → 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.
@@ -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
+ }
@@ -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
+ });
@@ -56,11 +56,10 @@ declare function resetEpochToMs(resetEpoch: number | undefined, now: number): nu
56
56
  */
57
57
  declare function planCooldownFor429(quota: AccountQuota | null, retryAfterMs: number, now: number, unifiedStatus?: string | undefined, policy?: ProxyOveragePolicy): AccountCooldownPlan;
58
58
  /**
59
- * Proactively cool an account when a SUCCESS response reveals a window has just
60
- * flipped to "rejected" (the boundary request that spends the last of the quota
61
- * still returns 200 but reports rejected/next-reset). Parks the account until
62
- * its reset so the next request skips it instead of discovering the limit via a
63
- * 429, except when the provider explicitly enables paid overage.
59
+ * Reconcile quota-backed cooldowns against a fresh provider observation. A
60
+ * rejected window parks the account until its reset; an allowed observation for
61
+ * that same window releases an earlier cooldown whose reset timestamp was
62
+ * stale. Transient and authentication cooldowns are intentionally untouched.
64
63
  */
65
64
  declare function reconcileCooldownFromQuota(state: RuntimeAccountState, quota: AccountQuota, now: number, policy?: ProxyOveragePolicy): ProxyQuotaCooldownUpdate;
66
65
  /**
@@ -604,11 +604,10 @@ function minutesUntil(untilMs, now) {
604
604
  return Math.max(0, Math.round((untilMs - now) / 60000));
605
605
  }
606
606
  /**
607
- * Proactively cool an account when a SUCCESS response reveals a window has just
608
- * flipped to "rejected" (the boundary request that spends the last of the quota
609
- * still returns 200 but reports rejected/next-reset). Parks the account until
610
- * its reset so the next request skips it instead of discovering the limit via a
611
- * 429, except when the provider explicitly enables paid overage.
607
+ * Reconcile quota-backed cooldowns against a fresh provider observation. A
608
+ * rejected window parks the account until its reset; an allowed observation for
609
+ * that same window releases an earlier cooldown whose reset timestamp was
610
+ * stale. Transient and authentication cooldowns are intentionally untouched.
612
611
  */
613
612
  function reconcileCooldownFromQuota(state, quota, now, policy = overagePolicy) {
614
613
  const overageAvailable = isOverageUsable(quota, policy);
@@ -641,6 +640,20 @@ function reconcileCooldownFromQuota(state, quota, now, policy = overagePolicy) {
641
640
  reason = "unified";
642
641
  }
643
642
  if (until === undefined) {
643
+ const recoveredQuotaCooldown = !!state.coolingUntil &&
644
+ ((state.coolingReason === "weekly" && quota.weeklyStatus === "allowed") ||
645
+ (state.coolingReason === "session" &&
646
+ quota.sessionStatus === "allowed") ||
647
+ (state.coolingReason === "unified" &&
648
+ quota.unifiedStatus === "allowed"));
649
+ if (recoveredQuotaCooldown && state.coolingUntil) {
650
+ const previousCoolingUntil = state.coolingUntil;
651
+ const previousCoolingReason = state.coolingReason;
652
+ state.coolingUntil = undefined;
653
+ state.coolingReason = undefined;
654
+ logger.always(`[proxy] clearing ${previousCoolingReason} cooldown from fresh provider quota`);
655
+ return { kind: "cleared", coolingUntil: previousCoolingUntil };
656
+ }
644
657
  return null;
645
658
  }
646
659
  const clamped = clampCooldownUntil(until, now, reason);
@@ -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.7.8",
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": {