@juspay/neurolink 12.8.0 → 12.9.1

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
+ }
@@ -123,3 +123,31 @@ registerLocalUsageReader({
123
123
  return createCursorReader();
124
124
  },
125
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
+ });
@@ -1,4 +1,6 @@
1
1
  import type { ProxyActivitySnapshot, ProxyResponseTrackingObserver } from "../types/index.js";
2
+ export declare function registerProxyResponseObserver(metadata: object, observer: ProxyResponseTrackingObserver): void;
3
+ export declare function takeProxyResponseObservers(metadata: object): ProxyResponseTrackingObserver[];
2
4
  /** Track one client-facing proxy request until its response body settles. */
3
5
  export declare function beginProxyRequest(): () => void;
4
6
  export declare function getProxyActivitySnapshot(): ProxyActivitySnapshot;
@@ -3,6 +3,24 @@ import { logger } from "../utils/logger.js";
3
3
  const PROXY_RESPONSE_CANCEL_TIMEOUT_MS = 1_000;
4
4
  let activeRequests = 0;
5
5
  let lastActivityAtMs = null;
6
+ // Route handlers can attach terminal observers to their request context without
7
+ // wrapping the response body a second time. The HTTP runtime drains every
8
+ // response through one tracker, which fans these observers out at the point
9
+ // where bytes actually leave the proxy.
10
+ const responseObserversByMetadata = new WeakMap();
11
+ export function registerProxyResponseObserver(metadata, observer) {
12
+ const existing = responseObserversByMetadata.get(metadata);
13
+ if (existing) {
14
+ existing.push(observer);
15
+ return;
16
+ }
17
+ responseObserversByMetadata.set(metadata, [observer]);
18
+ }
19
+ export function takeProxyResponseObservers(metadata) {
20
+ const observers = responseObserversByMetadata.get(metadata) ?? [];
21
+ responseObserversByMetadata.delete(metadata);
22
+ return observers;
23
+ }
6
24
  function touchActivity() {
7
25
  lastActivityAtMs = Date.now();
8
26
  }
@@ -24,7 +24,7 @@ import { loadAccountQuotas, saveAccountQuota, } from "../../proxy/accountQuota.j
24
24
  import { createCodexUsageTap } from "../../proxy/codexUsage.js";
25
25
  import { CODEX_ACCOUNT_PREFIX, parseCodexRateLimitHeaders, } from "../../proxy/codexAccountUsage.js";
26
26
  import { buildClientAttribution } from "../../proxy/clientAttribution.js";
27
- import { trackProxyResponse } from "../../proxy/proxyActivity.js";
27
+ import { registerProxyResponseObserver } from "../../proxy/proxyActivity.js";
28
28
  import { logRequest, logRequestAttempt } from "../../proxy/requestLogger.js";
29
29
  import { parseRetryAfterMs } from "../../proxy/routingPolicy.js";
30
30
  import { recordAttempt, recordAttemptError, recordFinalError, recordFinalSuccess, } from "../../proxy/usageStats.js";
@@ -459,7 +459,7 @@ export async function handleCodexResponsesRequest(ctx) {
459
459
  status: upstream.status,
460
460
  headers,
461
461
  });
462
- return trackProxyResponse(relay, () => undefined, {
462
+ registerProxyResponseObserver(ctx.metadata, {
463
463
  onTerminal: ({ outcome }) => {
464
464
  void usageSeen
465
465
  .then((usage) => {
@@ -491,6 +491,7 @@ export async function handleCodexResponsesRequest(ctx) {
491
491
  .catch(() => undefined);
492
492
  },
493
493
  });
494
+ return relay;
494
495
  }
495
496
  const errText = await upstream.text().catch(() => "");
496
497
  // 401/403 → try a forced token refresh once, then rotate.
@@ -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
  *
@@ -242,6 +242,61 @@ export type LocalUsageCopilotUsageRow = {
242
242
  reasoning_tokens: number | null;
243
243
  created_at: string | null;
244
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
+ };
245
300
  /**
246
301
  * The slice of `node:sqlite`'s `DatabaseSync` the OpenCode reader uses.
247
302
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "12.8.0",
3
+ "version": "12.9.1",
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": {