@juspay/neurolink
Version:
The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002
231 lines (230 loc) • 9.42 kB
JavaScript
/**
* Reads token usage out of Claude Code's own session transcripts.
*
* Layout, confirmed on a real machine rather than from documentation:
* `~/.claude/projects/<sanitized-cwd>/<sessionId>.jsonl`, where the sanitized
* cwd is the absolute working-directory path with every `/` replaced by `-`.
*
* The load-bearing detail is that top-level session transcripts are a tiny
* minority of the files. On the machine this was written against, 17,439
* transcripts totalling 9.7 GB broke down as roughly 103 top-level sessions
* against 17,116 subagent-task transcripts nested under
* `<sessionId>/subagents/`. A reader that globbed only `<project>/*.jsonl`
* would miss essentially all of a subagent-heavy user's real spend, so this
* one recurses.
*
* The files are not homogeneous message streams. A single 14,747-line
* transcript carried eleven distinct `type` values — `assistant`, `user`,
* `attachment`, `pr-link`, `last-prompt`, `mode`, `permission-mode`,
* `queue-operation`, `system`, `file-history-delta`, `file-history-snapshot` —
* several of which have no `message` key at all. Every one of its 6,028
* `type: "assistant"` lines carried a `message.usage` object, so that filter is
* safe, but everything else must be skipped rather than treated as malformed.
*/
import { createReadStream } from "fs";
import { createInterface } from "readline";
import { readdir, stat } from "fs/promises";
import { homedir } from "os";
import { join } from "path";
import { calculateCost, hasPricing } from "../utils/pricing.js";
import { resolveScanCutoffMs } from "./scanWindow.js";
const CLI_ID = "claude-code";
const PROVIDER = "anthropic";
function projectsRoot() {
return join(homedir(), ".claude", "projects");
}
function emptyTotals() {
return {
requests: 0,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheCreationTokens: 0,
costUsd: 0,
costConfidence: "modeled",
unpricedRequests: 0,
unpricedModels: [],
};
}
/**
* Every `.jsonl` under the projects root, at any depth.
*
* Depth matters here — see the module header. `withFileTypes` avoids a stat
* per entry, and a directory that cannot be read is skipped rather than
* aborting the walk: one unreadable project must not cost the other eleven
* thousand files.
*/
async function collectTranscripts(dir, out) {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
}
catch {
return;
}
for (const entry of entries) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
await collectTranscripts(full, out);
}
else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
out.push(full);
}
}
}
function num(value) {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
/**
* Fold one transcript into `totals`.
*
* Read line by line rather than into a string: these files reach tens of
* megabytes, and a full-history scan opens thousands of them.
*
* Dedup is by `message.id`, keeping the LARGEST output count seen for that id.
* A resumed session re-logs turns it has already written, and the re-logged
* copy can carry a higher output count than the first — taking the max rather
* than the first or last is what makes a resumed session total correct instead
* of either under- or double-counted. The map is per-file and discarded after,
* so it stays bounded no matter how many files a scan covers.
*/
async function foldTranscript(filePath, totals, unpriced) {
const seen = new Map();
const rl = createInterface({
input: createReadStream(filePath, { encoding: "utf8" }),
crlfDelay: Infinity,
});
try {
for await (const line of rl) {
if (!line || line.charCodeAt(0) !== 123 /* '{' */) {
continue;
}
let parsed;
try {
parsed = JSON.parse(line);
}
catch {
// A transcript being appended to while we read it ends in a partial
// line. That is normal, not corruption — skip it.
continue;
}
const record = parsed;
if (record.type !== "assistant" || !record.message?.usage) {
continue;
}
const usage = record.message.usage;
const id = record.message.id;
if (typeof id !== "string" || id.length === 0) {
continue;
}
const candidate = {
model: record.message.model ?? "unknown",
input: num(usage.input_tokens),
output: num(usage.output_tokens),
read: num(usage.cache_read_input_tokens),
create: num(usage.cache_creation_input_tokens),
};
const existing = seen.get(id);
if (!existing || candidate.output > existing.output) {
seen.set(id, candidate);
}
}
}
finally {
rl.close();
}
for (const turn of seen.values()) {
totals.requests += 1;
totals.inputTokens += turn.input;
totals.outputTokens += turn.output;
totals.cacheReadTokens += turn.read;
totals.cacheCreationTokens += turn.create;
if (hasPricing(PROVIDER, turn.model)) {
totals.costUsd += calculateCost(PROVIDER, turn.model, {
input: turn.input,
output: turn.output,
total: turn.input + turn.output,
cacheReadTokens: turn.read,
cacheCreationTokens: turn.create,
});
}
else {
totals.unpricedRequests += 1;
unpriced.add(turn.model);
}
}
}
export async function createClaudeCodeReader() {
return {
descriptor: {
id: CLI_ID,
displayName: "Claude Code",
verified: true,
dedupStrategy: "message-id-keep-max",
costConfidence: "modeled",
requiresSqlite: false,
},
detect: async () => {
try {
const info = await stat(projectsRoot());
return info.isDirectory();
}
catch {
return false;
}
},
scan: async (options) => {
const totals = emptyTotals();
const errors = [];
const unpriced = new Set();
const files = [];
await collectTranscripts(projectsRoot(), files);
// Only Infinity means "no time filter". A non-positive sinceDays used to
// leave the cutoff undefined and read EVERYTHING — measured at 17,534
// files and 35.9s for `sinceDays: 0`, which is the widest possible scan
// in answer to the narrowest possible request. Zero now means a
// zero-length window, which is what it reads as.
// NaN is not a window, and it is the case the previous fix missed:
// Math.max(0, NaN) is NaN, every comparison against NaN is false, so the
// filter passes EVERY file. Measured: sinceDays NaN read 17,537 files in
// 32.8s — the same unbounded sweep this guard exists to prevent, reached
// by a different door. The old guard caught it with Number.isFinite and
// the replacement dropped that check.
const cutoff = resolveScanCutoffMs(options?.sinceDays);
let filesScanned = 0;
for (const file of files) {
try {
if (cutoff !== undefined) {
const info = await stat(file);
if (info.mtimeMs < cutoff) {
continue;
}
}
await foldTranscript(file, totals, unpriced);
filesScanned += 1;
}
catch (error) {
// One unreadable transcript is a reportable fact, not a reason to
// lose the totals from every other file in the scan.
errors.push({
cliId: CLI_ID,
filePath: file,
message: error instanceof Error ? error.message : String(error),
});
}
}
totals.unpricedModels = [...unpriced].sort();
// Confidence stays "modeled" even when some turns went unpriced, and the
// distinction matters. "heuristic" means the number itself was estimated
// — Kiro's byte-count figure, say. Here every dollar in `costUsd` came
// from a real rate table; what is missing is turns, not accuracy. That
// incompleteness is already reported precisely by `unpricedRequests` and
// `unpricedModels` (on this machine: Claude Code's internal
// "<synthetic>" model), so downgrading the whole row would overstate the
// doubt and make a genuinely estimated total indistinguishable from this
// one.
totals.costUsd = Math.round(totals.costUsd * 1_000_000) / 1_000_000;
return { cliId: CLI_ID, totals, filesScanned, errors };
},
};
}