@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.
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Reads token usage out of Hermes Agent's SQLite state store.
3
+ *
4
+ * Hermes Agent is Nous Research's official CLI agent (github.com/NousResearch/
5
+ * hermes-agent), a Python program installed from its own install script — not
6
+ * an npm package. An earlier note in this folder said the opposite ("no
7
+ * official CLI, only an unofficial npm bridge"); it had searched npm for a
8
+ * product that is not distributed there, and is retracted here.
9
+ *
10
+ * Store: `$HERMES_HOME/state.db`, defaulting to `~/.hermes/state.db`, plus one
11
+ * `state.db` per profile under `profiles/<name>/`. SQLite, `schema_version`
12
+ * table (26 when this was written; `PRAGMA user_version` stays 0, so it is
13
+ * not the version to read). Confirmed on a real store produced by running the
14
+ * CLI itself, not inferred from documentation.
15
+ *
16
+ * Two tables carry usage, and they are NOT additive:
17
+ *
18
+ * sessions one row per session, with cumulative token columns
19
+ * for the PRIMARY task only.
20
+ * session_model_usage one row per (session, model, billing, task), each
21
+ * with its own `api_call_count`, token columns and cost.
22
+ *
23
+ * Measured on the real store: a one-prompt session held a `sessions` row of
24
+ * 10,568 input / 1 output / 1 call, and TWO usage rows — the primary task
25
+ * (`task = ''`, identical numbers) and a `title_generation` task of 248 / 8 /
26
+ * 1 call that the `sessions` aggregate does not include. The usage rows are
27
+ * therefore the complete record of what Hermes actually sent to a provider,
28
+ * and this reader sums them. The `sessions` aggregate is read only for a
29
+ * session that has no usage rows at all (a store older than the migration
30
+ * that introduced the table), and never in addition to them.
31
+ *
32
+ * Cost: Hermes records `estimated_cost_usd` with a `cost_status` of
33
+ * `estimated`, from its own pricing snapshot. That is a modeled figure and is
34
+ * reported as such. `actual_cost_usd` is `NOT NULL DEFAULT 0` on usage rows,
35
+ * so a zero there is a schema default, not evidence of a free call — it is
36
+ * used only when `cost_status` explicitly says the figure is actual. A row
37
+ * with no trustworthy cost is counted as unpriced and its model named.
38
+ *
39
+ * Cache and reasoning columns are reported as stored. Every real sample so far
40
+ * has them at zero, so whether `cache_read_tokens` is a subset of
41
+ * `input_tokens` (OpenAI convention) or disjoint from it (Anthropic
42
+ * convention) has not been measured, and no subtraction or folding is applied
43
+ * until it has. Reasoning tokens are not added to output for the same reason.
44
+ *
45
+ * The time window is a snapshot filter, not an attribution: every row is a
46
+ * cumulative counter, so a session that spans the cutoff is either included
47
+ * whole or excluded whole, keyed on its last activity. Timestamps are epoch
48
+ * SECONDS stored as REAL.
49
+ */
50
+ import { readdir, stat } from "fs/promises";
51
+ import { homedir } from "os";
52
+ import { join } from "path";
53
+ import { resolveScanCutoffMs } from "./scanWindow.js";
54
+ const CLI_ID = "hermes";
55
+ function hermesHome() {
56
+ const env = process.env.HERMES_HOME;
57
+ return env !== undefined && env.trim().length > 0
58
+ ? env
59
+ : join(homedir(), ".hermes");
60
+ }
61
+ function emptyTotals() {
62
+ return {
63
+ requests: 0,
64
+ inputTokens: 0,
65
+ outputTokens: 0,
66
+ cacheReadTokens: 0,
67
+ cacheCreationTokens: 0,
68
+ costUsd: 0,
69
+ costConfidence: "modeled",
70
+ unpricedRequests: 0,
71
+ unpricedModels: [],
72
+ };
73
+ }
74
+ /** A finite, non-negative number, or 0. NULL and garbage both read as 0. */
75
+ function count(value) {
76
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
77
+ ? value
78
+ : 0;
79
+ }
80
+ /** A finite, non-negative number, or null — for costs, where 0 is a value. */
81
+ function amount(value) {
82
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
83
+ ? value
84
+ : null;
85
+ }
86
+ /** The root store plus one per profile. Missing pieces are simply absent. */
87
+ async function findStateDatabases() {
88
+ const root = hermesHome();
89
+ const out = [];
90
+ const rootDb = join(root, "state.db");
91
+ try {
92
+ if ((await stat(rootDb)).isFile()) {
93
+ out.push(rootDb);
94
+ }
95
+ }
96
+ catch {
97
+ // No root store.
98
+ }
99
+ let profiles;
100
+ try {
101
+ profiles = (await readdir(join(root, "profiles"), { withFileTypes: true }))
102
+ .filter((entry) => entry.isDirectory())
103
+ .map((entry) => entry.name);
104
+ }
105
+ catch {
106
+ return out;
107
+ }
108
+ for (const name of profiles) {
109
+ const db = join(root, "profiles", name, "state.db");
110
+ try {
111
+ if ((await stat(db)).isFile()) {
112
+ out.push(db);
113
+ }
114
+ }
115
+ catch {
116
+ // Profile without a store yet.
117
+ }
118
+ }
119
+ return out;
120
+ }
121
+ function columnsOf(db, table) {
122
+ // PRAGMA table_info cannot take a bound parameter; the table names here are
123
+ // fixed identifiers, never user input.
124
+ const rows = db.prepare(`PRAGMA table_info(${table})`).all();
125
+ return new Set(rows
126
+ .map((row) => row.name)
127
+ .filter((name) => typeof name === "string"));
128
+ }
129
+ const USAGE_REQUIRED = [
130
+ "session_id",
131
+ "model",
132
+ "api_call_count",
133
+ "input_tokens",
134
+ "output_tokens",
135
+ ];
136
+ const SESSIONS_REQUIRED = [
137
+ "id",
138
+ "started_at",
139
+ "input_tokens",
140
+ "output_tokens",
141
+ ];
142
+ const OPTIONAL_COUNTS = [
143
+ "cache_read_tokens",
144
+ "cache_write_tokens",
145
+ "reasoning_tokens",
146
+ ];
147
+ const OPTIONAL_COST = [
148
+ "estimated_cost_usd",
149
+ "actual_cost_usd",
150
+ "cost_status",
151
+ ];
152
+ /** `col` if the table has it, else a typed default under the same alias. */
153
+ function select(have, col, fallback, alias = col) {
154
+ return have.has(col)
155
+ ? `${alias === col ? col : `${col} AS ${alias}`}`
156
+ : `${fallback} AS ${alias}`;
157
+ }
158
+ /**
159
+ * Which cost a row is allowed to claim.
160
+ *
161
+ * `cost_status` is provenance, and it governs which column is trusted: an
162
+ * explicit actual/billed status admits `actual_cost_usd`; an explicit
163
+ * estimated status admits `estimated_cost_usd`. Without a status, a POSITIVE
164
+ * estimate is still a computed value rather than a schema default and is
165
+ * accepted; a bare zero is not, because `actual_cost_usd` defaults to 0 on
166
+ * every usage row and would otherwise price every call at nothing.
167
+ */
168
+ function rowCost(row) {
169
+ const status = typeof row.cost_status === "string" ? row.cost_status.toLowerCase() : "";
170
+ const actual = amount(row.actual_cost_usd);
171
+ const estimated = amount(row.estimated_cost_usd);
172
+ if (status === "actual" || status === "billed") {
173
+ return actual;
174
+ }
175
+ if (status === "estimated") {
176
+ return estimated;
177
+ }
178
+ return estimated !== null && estimated > 0 ? estimated : null;
179
+ }
180
+ /**
181
+ * `COALESCE` over the timestamp columns a schema actually has. SQLite rejects
182
+ * a one-argument COALESCE outright, and a minimal schema (or a fixture
183
+ * modelled on one) can have exactly one — `started_at` is the only required
184
+ * timestamp — so a single column is emitted bare.
185
+ */
186
+ function newestOf(cols) {
187
+ const [only] = cols;
188
+ return cols.length === 1 && only !== undefined
189
+ ? only
190
+ : `COALESCE(${cols.join(", ")})`;
191
+ }
192
+ function foldRow(row, totals, unpriced) {
193
+ const calls = count(row.api_call_count);
194
+ const input = count(row.input_tokens);
195
+ const output = count(row.output_tokens);
196
+ if (calls === 0 && input + output === 0) {
197
+ // A failed one-shot leaves a session with no calls and no tokens. It is
198
+ // not usage, and counting it as a zero-token request would inflate the
199
+ // request count with attempts that never reached a model.
200
+ return;
201
+ }
202
+ totals.requests += calls > 0 ? calls : 1;
203
+ totals.inputTokens += input;
204
+ totals.outputTokens += output;
205
+ totals.cacheReadTokens += count(row.cache_read_tokens);
206
+ totals.cacheCreationTokens += count(row.cache_write_tokens);
207
+ const cost = rowCost(row);
208
+ if (cost === null) {
209
+ totals.unpricedRequests += calls > 0 ? calls : 1;
210
+ unpriced.add(typeof row.model === "string" && row.model ? row.model : "unknown");
211
+ }
212
+ else {
213
+ totals.costUsd += cost;
214
+ }
215
+ }
216
+ function readStore(db, dbPath, cutoffSeconds, totals, unpriced, errors) {
217
+ const sessionCols = columnsOf(db, "sessions");
218
+ if (sessionCols.size === 0) {
219
+ errors.push({
220
+ cliId: CLI_ID,
221
+ filePath: dbPath,
222
+ message: "no sessions table — not a Hermes state store, or one this reader does not understand",
223
+ });
224
+ return;
225
+ }
226
+ const missingSessions = SESSIONS_REQUIRED.filter((c) => !sessionCols.has(c));
227
+ if (missingSessions.length > 0) {
228
+ // Fail closed. Reading a partial schema and reporting a clean zero would
229
+ // look like "no usage" when the truth is "unreadable".
230
+ errors.push({
231
+ cliId: CLI_ID,
232
+ filePath: dbPath,
233
+ message: `sessions table lacks required column(s) ${missingSessions.join(", ")} — Hermes' schema has changed`,
234
+ });
235
+ return;
236
+ }
237
+ const usageCols = columnsOf(db, "session_model_usage");
238
+ const hasUsage = usageCols.size > 0 && USAGE_REQUIRED.every((c) => usageCols.has(c));
239
+ // Last activity, in seconds. Sessions know theirs; usage rows fall back to
240
+ // their session's when they carry no `last_seen` of their own.
241
+ const sessionAt = [
242
+ sessionCols.has("last_activity_at") ? "s.last_activity_at" : null,
243
+ sessionCols.has("ended_at") ? "s.ended_at" : null,
244
+ "s.started_at",
245
+ ].filter((c) => c !== null);
246
+ if (hasUsage) {
247
+ const at = [
248
+ usageCols.has("last_seen") ? "u.last_seen" : null,
249
+ usageCols.has("first_seen") ? "u.first_seen" : null,
250
+ ...sessionAt,
251
+ ].filter((c) => c !== null);
252
+ const sql = `SELECT u.session_id, u.model, u.api_call_count, u.input_tokens, u.output_tokens, ${OPTIONAL_COUNTS.map((c) => (usageCols.has(c) ? `u.${c}` : `0 AS ${c}`)).join(", ")}, ${OPTIONAL_COST.map((c) => usageCols.has(c) ? `u.${c}` : `NULL AS ${c}`).join(", ")}, ${newestOf(at)} AS at FROM session_model_usage u LEFT JOIN sessions s ON s.id = u.session_id`;
253
+ const rows = db.prepare(sql).all();
254
+ for (const row of rows) {
255
+ if (cutoffSeconds !== undefined && count(row.at) < cutoffSeconds) {
256
+ continue;
257
+ }
258
+ foldRow(row, totals, unpriced);
259
+ }
260
+ }
261
+ // Sessions with no usage rows: only the aggregate exists for them.
262
+ const orphanFilter = hasUsage
263
+ ? " WHERE NOT EXISTS (SELECT 1 FROM session_model_usage u WHERE u.session_id = s.id)"
264
+ : "";
265
+ const sessionSql = `SELECT s.id AS session_id, ${select(sessionCols, "model", "NULL")}, ${select(sessionCols, "api_call_count", "0")}, s.input_tokens, s.output_tokens, ${OPTIONAL_COUNTS.map((c) => sessionCols.has(c) ? `s.${c}` : `0 AS ${c}`).join(", ")}, ${OPTIONAL_COST.map((c) => sessionCols.has(c) ? `s.${c}` : `NULL AS ${c}`).join(", ")}, ${newestOf(sessionAt)} AS at FROM sessions s${orphanFilter}`;
266
+ const sessions = db.prepare(sessionSql).all();
267
+ for (const row of sessions) {
268
+ if (cutoffSeconds !== undefined && count(row.at) < cutoffSeconds) {
269
+ continue;
270
+ }
271
+ foldRow(row, totals, unpriced);
272
+ }
273
+ }
274
+ export async function createHermesReader() {
275
+ return {
276
+ descriptor: {
277
+ id: CLI_ID,
278
+ displayName: "Hermes Agent",
279
+ verified: true,
280
+ // One row per (session, model, task): the store already holds each call
281
+ // exactly once, so the only thing to avoid is reading a session's
282
+ // aggregate on top of its rows.
283
+ dedupStrategy: "last-write-wins",
284
+ costConfidence: "modeled",
285
+ requiresSqlite: true,
286
+ },
287
+ detect: async () => (await findStateDatabases()).length > 0,
288
+ scan: async (options) => {
289
+ const totals = emptyTotals();
290
+ const errors = [];
291
+ const unpriced = new Set();
292
+ let DatabaseSync;
293
+ try {
294
+ const sqlite = await import("node:sqlite");
295
+ if (typeof sqlite === "object" &&
296
+ sqlite !== null &&
297
+ "DatabaseSync" in sqlite &&
298
+ typeof sqlite.DatabaseSync ===
299
+ "function") {
300
+ DatabaseSync = sqlite.DatabaseSync;
301
+ }
302
+ }
303
+ catch (error) {
304
+ errors.push({
305
+ cliId: CLI_ID,
306
+ filePath: hermesHome(),
307
+ message: `node:sqlite unavailable on this runtime: ${error instanceof Error ? error.message : String(error)}`,
308
+ });
309
+ return { cliId: CLI_ID, totals, filesScanned: 0, errors };
310
+ }
311
+ if (!DatabaseSync) {
312
+ errors.push({
313
+ cliId: CLI_ID,
314
+ filePath: hermesHome(),
315
+ message: "node:sqlite did not expose a callable DatabaseSync — the experimental API has likely changed shape",
316
+ });
317
+ return { cliId: CLI_ID, totals, filesScanned: 0, errors };
318
+ }
319
+ const cutoffMs = resolveScanCutoffMs(options?.sinceDays);
320
+ const cutoffSeconds = cutoffMs === undefined ? undefined : cutoffMs / 1000;
321
+ let filesScanned = 0;
322
+ for (const dbPath of await findStateDatabases()) {
323
+ let db;
324
+ try {
325
+ // Read-only: Hermes may be writing to this file right now.
326
+ db = new DatabaseSync(dbPath, { readOnly: true });
327
+ filesScanned += 1;
328
+ readStore(db, dbPath, cutoffSeconds, totals, unpriced, errors);
329
+ }
330
+ catch (error) {
331
+ errors.push({
332
+ cliId: CLI_ID,
333
+ filePath: dbPath,
334
+ message: error instanceof Error ? error.message : String(error),
335
+ });
336
+ }
337
+ finally {
338
+ try {
339
+ db?.close();
340
+ }
341
+ catch {
342
+ // Already closed, or never opened.
343
+ }
344
+ }
345
+ }
346
+ totals.unpricedModels = [...unpriced].sort();
347
+ return { cliId: CLI_ID, totals, filesScanned, errors };
348
+ },
349
+ };
350
+ }
@@ -108,3 +108,46 @@ 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
+ });
126
+ registerLocalUsageReader({
127
+ descriptor: {
128
+ id: "grok",
129
+ displayName: "Grok Build",
130
+ verified: true,
131
+ dedupStrategy: "last-write-wins",
132
+ costConfidence: "unavailable",
133
+ requiresSqlite: false,
134
+ },
135
+ factory: async () => {
136
+ const { createGrokReader } = await import("./grokReader.js");
137
+ return createGrokReader();
138
+ },
139
+ });
140
+ registerLocalUsageReader({
141
+ descriptor: {
142
+ id: "hermes",
143
+ displayName: "Hermes Agent",
144
+ verified: true,
145
+ dedupStrategy: "last-write-wins",
146
+ costConfidence: "modeled",
147
+ requiresSqlite: true,
148
+ },
149
+ factory: async () => {
150
+ const { createHermesReader } = await import("./hermesReader.js");
151
+ return createHermesReader();
152
+ },
153
+ });
@@ -22,7 +22,7 @@ export type LocalUsageCliId = "claude-code" | "codex" | "gemini-cli" | "opencode
22
22
  * that names it. `usage local --cli copilot-cli` still resolves, normalised
23
23
  * to "copilot" at the input boundary.
24
24
  */
25
- | "copilot-cli" | "cursor" | "amp" | "hermes" | "kiro" | "antigravity" | "grok";
25
+ | "copilot-cli" | "cursor" | "grok" | "hermes" | "amp" | "kiro" | "antigravity";
26
26
  /**
27
27
  * How much to trust a computed cost figure.
28
28
  *
@@ -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 = {
@@ -222,6 +242,61 @@ export type LocalUsageCopilotUsageRow = {
222
242
  reasoning_tokens: number | null;
223
243
  created_at: string | null;
224
244
  };
245
+ /**
246
+ * One usage row as this subsystem reads it out of Hermes Agent's `state.db` —
247
+ * either a `session_model_usage` row, or a `sessions` row projected onto the
248
+ * same columns for a session that predates that table. Every column but the
249
+ * first five may be absent from an older schema and is selected with a
250
+ * default, so they are optional here. `at` is the row's last activity in
251
+ * epoch SECONDS, coalesced from whichever timestamp the schema has.
252
+ */
253
+ export type LocalUsageHermesUsageRow = {
254
+ session_id: string;
255
+ model: string | null;
256
+ api_call_count: number | null;
257
+ input_tokens: number | null;
258
+ output_tokens: number | null;
259
+ cache_read_tokens?: number | null;
260
+ cache_write_tokens?: number | null;
261
+ reasoning_tokens?: number | null;
262
+ estimated_cost_usd?: number | null;
263
+ actual_cost_usd?: number | null;
264
+ cost_status?: string | null;
265
+ at?: number | null;
266
+ };
267
+ /**
268
+ * The `usage` object on a Grok Build `turn_completed` session update, as
269
+ * appended to a session's `updates.jsonl`. camelCase, from the CLI's own
270
+ * serde definitions and confirmed on a real run. `modelUsage` holds the same
271
+ * shape per model id; `numTurns` is the process ledger's turn counter, which
272
+ * is how a reader tells a cumulative run from a fresh one — see
273
+ * `grokReader.ts`.
274
+ */
275
+ export type LocalUsageGrokTurnUsage = {
276
+ inputTokens?: number;
277
+ outputTokens?: number;
278
+ cachedReadTokens?: number;
279
+ cacheCreationTokens?: number;
280
+ reasoningTokens?: number;
281
+ modelCalls?: number;
282
+ numTurns?: number;
283
+ modelUsage?: Record<string, unknown>;
284
+ };
285
+ /**
286
+ * One Grok Build completed turn after validation: every count a finite,
287
+ * non-negative safe integer, and the `modelUsage` keys collected. `turns` is
288
+ * the ledger's `numTurns`, which decides whether the next record continues
289
+ * this process run or starts a fresh one — see `grokReader.ts`.
290
+ */
291
+ export type LocalUsageGrokTurn = {
292
+ input: number;
293
+ output: number;
294
+ cacheRead: number;
295
+ cacheCreation: number;
296
+ calls: number;
297
+ turns: number;
298
+ models: string[];
299
+ };
225
300
  /**
226
301
  * The slice of `node:sqlite`'s `DatabaseSync` the OpenCode reader uses.
227
302
  *
@@ -230,6 +305,28 @@ export type LocalUsageCopilotUsageRow = {
230
305
  * runtime rather than trusting a type assertion — naming only what is actually
231
306
  * called keeps that check small and honest.
232
307
  */
308
+ /**
309
+ * One decoded protobuf field from a Cursor root blob. Wire types 0 (varint)
310
+ * and 2 (length-delimited) only — the two Cursor actually uses; fixed-width
311
+ * fields are skipped by the decoder rather than represented.
312
+ */
313
+ export type LocalUsageWireField = {
314
+ field: number;
315
+ kind: "varint";
316
+ value: number;
317
+ } | {
318
+ field: number;
319
+ kind: "bytes";
320
+ value: Uint8Array;
321
+ };
322
+ /**
323
+ * One entry in Cursor's context breakdown — `system_prompt`, `tools`, `rules`
324
+ * and friends — carrying the token count that entry occupies in context.
325
+ */
326
+ export type LocalUsageContextPart = {
327
+ name: string;
328
+ tokens: number;
329
+ };
233
330
  export type LocalUsageSqliteDatabase = {
234
331
  prepare: (sql: string) => {
235
332
  all: (...params: unknown[]) => unknown[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "12.7.9",
3
+ "version": "12.9.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": {