@juspay/neurolink 11.18.5 → 11.20.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/dist/index.d.ts CHANGED
@@ -445,3 +445,4 @@ export { TripwireEvaluator, createDefaultTripwireEvaluator, commonTripwires, } f
445
445
  export { Agent } from "./agent/agent.js";
446
446
  export { AgentNetwork } from "./agent/agentNetwork.js";
447
447
  export { AgentCoordinator, TaskDistributor, MessageBus, NetworkOrchestrator, NetworkTopology, TopologyBuilder, } from "./agent/index.js";
448
+ export { createLocalUsageReader, getLocalUsageDescriptors, getRegisteredLocalUsageCliIds, readAllLocalUsage, registerLocalUsageReader, } from "./localUsage/index.js";
package/dist/index.js CHANGED
@@ -745,3 +745,10 @@ AgentCoordinator, TaskDistributor,
745
745
  MessageBus,
746
746
  // Orchestration
747
747
  NetworkOrchestrator, NetworkTopology, TopologyBuilder, } from "./agent/index.js";
748
+ // ============================================================================
749
+ // Local usage exports
750
+ // ============================================================================
751
+ // Token spend read from each CLI's own session logs, rather than from proxy
752
+ // traffic. Exported because the proxy's ledger only sees CLIs that route
753
+ // through it; this covers the rest, and covers history predating the proxy.
754
+ export { createLocalUsageReader, getLocalUsageDescriptors, getRegisteredLocalUsageCliIds, readAllLocalUsage, registerLocalUsageReader, } from "./localUsage/index.js";
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Reads token usage out of Claude Code's own session transcripts.
3
+ *
4
+ * Layout, confirmed on a real machine rather than from documentation:
5
+ * `~/.claude/projects/<sanitized-cwd>/<sessionId>.jsonl`, where the sanitized
6
+ * cwd is the absolute working-directory path with every `/` replaced by `-`.
7
+ *
8
+ * The load-bearing detail is that top-level session transcripts are a tiny
9
+ * minority of the files. On the machine this was written against, 17,439
10
+ * transcripts totalling 9.7 GB broke down as roughly 103 top-level sessions
11
+ * against 17,116 subagent-task transcripts nested under
12
+ * `<sessionId>/subagents/`. A reader that globbed only `<project>/*.jsonl`
13
+ * would miss essentially all of a subagent-heavy user's real spend, so this
14
+ * one recurses.
15
+ *
16
+ * The files are not homogeneous message streams. A single 14,747-line
17
+ * transcript carried eleven distinct `type` values — `assistant`, `user`,
18
+ * `attachment`, `pr-link`, `last-prompt`, `mode`, `permission-mode`,
19
+ * `queue-operation`, `system`, `file-history-delta`, `file-history-snapshot` —
20
+ * several of which have no `message` key at all. Every one of its 6,028
21
+ * `type: "assistant"` lines carried a `message.usage` object, so that filter is
22
+ * safe, but everything else must be skipped rather than treated as malformed.
23
+ */
24
+ import type { LocalUsageReader } from "../types/index.js";
25
+ export declare function createClaudeCodeReader(): Promise<LocalUsageReader>;
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Reads token usage out of Claude Code's own session transcripts.
3
+ *
4
+ * Layout, confirmed on a real machine rather than from documentation:
5
+ * `~/.claude/projects/<sanitized-cwd>/<sessionId>.jsonl`, where the sanitized
6
+ * cwd is the absolute working-directory path with every `/` replaced by `-`.
7
+ *
8
+ * The load-bearing detail is that top-level session transcripts are a tiny
9
+ * minority of the files. On the machine this was written against, 17,439
10
+ * transcripts totalling 9.7 GB broke down as roughly 103 top-level sessions
11
+ * against 17,116 subagent-task transcripts nested under
12
+ * `<sessionId>/subagents/`. A reader that globbed only `<project>/*.jsonl`
13
+ * would miss essentially all of a subagent-heavy user's real spend, so this
14
+ * one recurses.
15
+ *
16
+ * The files are not homogeneous message streams. A single 14,747-line
17
+ * transcript carried eleven distinct `type` values — `assistant`, `user`,
18
+ * `attachment`, `pr-link`, `last-prompt`, `mode`, `permission-mode`,
19
+ * `queue-operation`, `system`, `file-history-delta`, `file-history-snapshot` —
20
+ * several of which have no `message` key at all. Every one of its 6,028
21
+ * `type: "assistant"` lines carried a `message.usage` object, so that filter is
22
+ * safe, but everything else must be skipped rather than treated as malformed.
23
+ */
24
+ import { createReadStream } from "fs";
25
+ import { createInterface } from "readline";
26
+ import { readdir, stat } from "fs/promises";
27
+ import { homedir } from "os";
28
+ import { join } from "path";
29
+ import { calculateCost, hasPricing } from "../utils/pricing.js";
30
+ const CLI_ID = "claude-code";
31
+ const DEFAULT_SINCE_DAYS = 30;
32
+ const PROVIDER = "anthropic";
33
+ function projectsRoot() {
34
+ return join(homedir(), ".claude", "projects");
35
+ }
36
+ function emptyTotals() {
37
+ return {
38
+ requests: 0,
39
+ inputTokens: 0,
40
+ outputTokens: 0,
41
+ cacheReadTokens: 0,
42
+ cacheCreationTokens: 0,
43
+ costUsd: 0,
44
+ costConfidence: "modeled",
45
+ unpricedRequests: 0,
46
+ unpricedModels: [],
47
+ };
48
+ }
49
+ /**
50
+ * Every `.jsonl` under the projects root, at any depth.
51
+ *
52
+ * Depth matters here — see the module header. `withFileTypes` avoids a stat
53
+ * per entry, and a directory that cannot be read is skipped rather than
54
+ * aborting the walk: one unreadable project must not cost the other eleven
55
+ * thousand files.
56
+ */
57
+ async function collectTranscripts(dir, out) {
58
+ let entries;
59
+ try {
60
+ entries = await readdir(dir, { withFileTypes: true });
61
+ }
62
+ catch {
63
+ return;
64
+ }
65
+ for (const entry of entries) {
66
+ const full = join(dir, entry.name);
67
+ if (entry.isDirectory()) {
68
+ await collectTranscripts(full, out);
69
+ }
70
+ else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
71
+ out.push(full);
72
+ }
73
+ }
74
+ }
75
+ function num(value) {
76
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
77
+ }
78
+ /**
79
+ * Fold one transcript into `totals`.
80
+ *
81
+ * Read line by line rather than into a string: these files reach tens of
82
+ * megabytes, and a full-history scan opens thousands of them.
83
+ *
84
+ * Dedup is by `message.id`, keeping the LARGEST output count seen for that id.
85
+ * A resumed session re-logs turns it has already written, and the re-logged
86
+ * copy can carry a higher output count than the first — taking the max rather
87
+ * than the first or last is what makes a resumed session total correct instead
88
+ * of either under- or double-counted. The map is per-file and discarded after,
89
+ * so it stays bounded no matter how many files a scan covers.
90
+ */
91
+ async function foldTranscript(filePath, totals, unpriced) {
92
+ const seen = new Map();
93
+ const rl = createInterface({
94
+ input: createReadStream(filePath, { encoding: "utf8" }),
95
+ crlfDelay: Infinity,
96
+ });
97
+ try {
98
+ for await (const line of rl) {
99
+ if (!line || line.charCodeAt(0) !== 123 /* '{' */) {
100
+ continue;
101
+ }
102
+ let parsed;
103
+ try {
104
+ parsed = JSON.parse(line);
105
+ }
106
+ catch {
107
+ // A transcript being appended to while we read it ends in a partial
108
+ // line. That is normal, not corruption — skip it.
109
+ continue;
110
+ }
111
+ const record = parsed;
112
+ if (record.type !== "assistant" || !record.message?.usage) {
113
+ continue;
114
+ }
115
+ const usage = record.message.usage;
116
+ const id = record.message.id;
117
+ if (typeof id !== "string" || id.length === 0) {
118
+ continue;
119
+ }
120
+ const candidate = {
121
+ model: record.message.model ?? "unknown",
122
+ input: num(usage.input_tokens),
123
+ output: num(usage.output_tokens),
124
+ read: num(usage.cache_read_input_tokens),
125
+ create: num(usage.cache_creation_input_tokens),
126
+ };
127
+ const existing = seen.get(id);
128
+ if (!existing || candidate.output > existing.output) {
129
+ seen.set(id, candidate);
130
+ }
131
+ }
132
+ }
133
+ finally {
134
+ rl.close();
135
+ }
136
+ for (const turn of seen.values()) {
137
+ totals.requests += 1;
138
+ totals.inputTokens += turn.input;
139
+ totals.outputTokens += turn.output;
140
+ totals.cacheReadTokens += turn.read;
141
+ totals.cacheCreationTokens += turn.create;
142
+ if (hasPricing(PROVIDER, turn.model)) {
143
+ totals.costUsd += calculateCost(PROVIDER, turn.model, {
144
+ input: turn.input,
145
+ output: turn.output,
146
+ total: turn.input + turn.output,
147
+ cacheReadTokens: turn.read,
148
+ cacheCreationTokens: turn.create,
149
+ });
150
+ }
151
+ else {
152
+ totals.unpricedRequests += 1;
153
+ unpriced.add(turn.model);
154
+ }
155
+ }
156
+ }
157
+ export async function createClaudeCodeReader() {
158
+ return {
159
+ descriptor: {
160
+ id: CLI_ID,
161
+ displayName: "Claude Code",
162
+ verified: true,
163
+ dedupStrategy: "message-id-keep-max",
164
+ costConfidence: "modeled",
165
+ requiresSqlite: false,
166
+ },
167
+ detect: async () => {
168
+ try {
169
+ const info = await stat(projectsRoot());
170
+ return info.isDirectory();
171
+ }
172
+ catch {
173
+ return false;
174
+ }
175
+ },
176
+ scan: async (options) => {
177
+ const totals = emptyTotals();
178
+ const errors = [];
179
+ const unpriced = new Set();
180
+ const files = [];
181
+ await collectTranscripts(projectsRoot(), files);
182
+ const sinceDays = options?.sinceDays ?? DEFAULT_SINCE_DAYS;
183
+ const cutoff = Number.isFinite(sinceDays) && sinceDays > 0
184
+ ? Date.now() - sinceDays * 86_400_000
185
+ : undefined;
186
+ let filesScanned = 0;
187
+ for (const file of files) {
188
+ try {
189
+ if (cutoff !== undefined) {
190
+ const info = await stat(file);
191
+ if (info.mtimeMs < cutoff) {
192
+ continue;
193
+ }
194
+ }
195
+ await foldTranscript(file, totals, unpriced);
196
+ filesScanned += 1;
197
+ }
198
+ catch (error) {
199
+ // One unreadable transcript is a reportable fact, not a reason to
200
+ // lose the totals from every other file in the scan.
201
+ errors.push({
202
+ cliId: CLI_ID,
203
+ filePath: file,
204
+ message: error instanceof Error ? error.message : String(error),
205
+ });
206
+ }
207
+ }
208
+ totals.unpricedModels = [...unpriced].sort();
209
+ // Confidence stays "modeled" even when some turns went unpriced, and the
210
+ // distinction matters. "heuristic" means the number itself was estimated
211
+ // — Kiro's byte-count figure, say. Here every dollar in `costUsd` came
212
+ // from a real rate table; what is missing is turns, not accuracy. That
213
+ // incompleteness is already reported precisely by `unpricedRequests` and
214
+ // `unpricedModels` (on this machine: Claude Code's internal
215
+ // "<synthetic>" model), so downgrading the whole row would overstate the
216
+ // doubt and make a genuinely estimated total indistinguishable from this
217
+ // one.
218
+ totals.costUsd = Math.round(totals.costUsd * 1_000_000) / 1_000_000;
219
+ return { cliId: CLI_ID, totals, filesScanned, errors };
220
+ },
221
+ };
222
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Reads token usage out of Codex's own rollout transcripts.
3
+ *
4
+ * Layout: `~/.codex/sessions/<yyyy>/<mm>/<dd>/rollout-<iso>-<sessionId>.jsonl`.
5
+ * Each line is one of `session_meta`, `turn_context`, `response_item` or
6
+ * `event_msg`; usage lives on `event_msg` records whose `payload.type` is
7
+ * `token_count`.
8
+ *
9
+ * The counter semantics are the whole story here, and they are the opposite of
10
+ * the Claude Code reader's. Every `token_count` event carries BOTH a
11
+ * cumulative `total_token_usage` for the session and a per-turn
12
+ * `last_token_usage`. Summing the per-turn values is the obvious move and it is
13
+ * wrong: measured across all 108 sessions on a real machine, summing yields
14
+ * 9,191,613,238 tokens against a true 5,653,217,442 — an overstatement of
15
+ * 62.6%, and 195% on the worst single session. The per-turn value repeats
16
+ * across events within a turn, so it double-counts.
17
+ *
18
+ * The cumulative counter is authoritative and was monotonic in all 108
19
+ * sessions — it never resets mid-file — so the last one in the file is the
20
+ * session's true total. `Math.max` is still used rather than "last seen",
21
+ * because a counter that is only monotonic in every case observed is not the
22
+ * same as one that is guaranteed to be, and the max costs nothing.
23
+ *
24
+ * Cost is deliberately not computed. Codex is a ChatGPT subscription — the
25
+ * rollouts carry `rate_limits.plan_type` — so a per-token dollar figure would
26
+ * be an invention, not a measurement. The tokens are real; the cost is
27
+ * `unavailable`.
28
+ */
29
+ import type { LocalUsageReader } from "../types/index.js";
30
+ export declare function createCodexReader(): Promise<LocalUsageReader>;
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Reads token usage out of Codex's own rollout transcripts.
3
+ *
4
+ * Layout: `~/.codex/sessions/<yyyy>/<mm>/<dd>/rollout-<iso>-<sessionId>.jsonl`.
5
+ * Each line is one of `session_meta`, `turn_context`, `response_item` or
6
+ * `event_msg`; usage lives on `event_msg` records whose `payload.type` is
7
+ * `token_count`.
8
+ *
9
+ * The counter semantics are the whole story here, and they are the opposite of
10
+ * the Claude Code reader's. Every `token_count` event carries BOTH a
11
+ * cumulative `total_token_usage` for the session and a per-turn
12
+ * `last_token_usage`. Summing the per-turn values is the obvious move and it is
13
+ * wrong: measured across all 108 sessions on a real machine, summing yields
14
+ * 9,191,613,238 tokens against a true 5,653,217,442 — an overstatement of
15
+ * 62.6%, and 195% on the worst single session. The per-turn value repeats
16
+ * across events within a turn, so it double-counts.
17
+ *
18
+ * The cumulative counter is authoritative and was monotonic in all 108
19
+ * sessions — it never resets mid-file — so the last one in the file is the
20
+ * session's true total. `Math.max` is still used rather than "last seen",
21
+ * because a counter that is only monotonic in every case observed is not the
22
+ * same as one that is guaranteed to be, and the max costs nothing.
23
+ *
24
+ * Cost is deliberately not computed. Codex is a ChatGPT subscription — the
25
+ * rollouts carry `rate_limits.plan_type` — so a per-token dollar figure would
26
+ * be an invention, not a measurement. The tokens are real; the cost is
27
+ * `unavailable`.
28
+ */
29
+ import { createReadStream } from "fs";
30
+ import { createInterface } from "readline";
31
+ import { readdir, stat } from "fs/promises";
32
+ import { homedir } from "os";
33
+ import { join } from "path";
34
+ const CLI_ID = "codex";
35
+ const DEFAULT_SINCE_DAYS = 30;
36
+ function sessionsRoot() {
37
+ return join(homedir(), ".codex", "sessions");
38
+ }
39
+ function emptyTotals() {
40
+ return {
41
+ requests: 0,
42
+ inputTokens: 0,
43
+ outputTokens: 0,
44
+ cacheReadTokens: 0,
45
+ cacheCreationTokens: 0,
46
+ costUsd: 0,
47
+ costConfidence: "unavailable",
48
+ unpricedRequests: 0,
49
+ unpricedModels: [],
50
+ };
51
+ }
52
+ async function collectRollouts(dir, out) {
53
+ let entries;
54
+ try {
55
+ entries = await readdir(dir, { withFileTypes: true });
56
+ }
57
+ catch {
58
+ // One unreadable date directory must not cost the rest of the scan.
59
+ return;
60
+ }
61
+ for (const entry of entries) {
62
+ const full = join(dir, entry.name);
63
+ if (entry.isDirectory()) {
64
+ await collectRollouts(full, out);
65
+ }
66
+ else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
67
+ out.push(full);
68
+ }
69
+ }
70
+ }
71
+ function num(value) {
72
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
73
+ }
74
+ /**
75
+ * Fold one rollout into a single session-level rollup.
76
+ *
77
+ * `billableEvents` counts only the `token_count` events where the cumulative
78
+ * total actually advanced. Counting every event instead would inherit exactly
79
+ * the repetition that makes summing the per-turn values wrong.
80
+ */
81
+ async function readRollout(filePath) {
82
+ const rl = createInterface({
83
+ input: createReadStream(filePath, { encoding: "utf8" }),
84
+ crlfDelay: Infinity,
85
+ });
86
+ let model;
87
+ let bestTotal = -1;
88
+ let best;
89
+ let previousTotal = -1;
90
+ let billableEvents = 0;
91
+ try {
92
+ for await (const line of rl) {
93
+ // Cheap reject before JSON.parse: these files reach hundreds of MB and
94
+ // most lines are conversation content with no usage on them at all.
95
+ const isTokenCount = line.includes('"token_count"');
96
+ const isTurnContext = line.includes('"turn_context"');
97
+ if (!isTokenCount && !isTurnContext) {
98
+ continue;
99
+ }
100
+ let parsed;
101
+ try {
102
+ parsed = JSON.parse(line);
103
+ }
104
+ catch {
105
+ // A rollout being appended to while we read it ends mid-line.
106
+ continue;
107
+ }
108
+ const record = parsed;
109
+ if (record.type === "turn_context" && record.payload?.model) {
110
+ // Last one wins: a session can switch models partway through, and the
111
+ // most recent is the better single label for it.
112
+ model = record.payload.model;
113
+ continue;
114
+ }
115
+ if (record.payload?.type !== "token_count") {
116
+ continue;
117
+ }
118
+ const cumulative = record.payload.info?.total_token_usage;
119
+ if (!cumulative) {
120
+ continue;
121
+ }
122
+ const total = num(cumulative.total_tokens);
123
+ if (total > previousTotal) {
124
+ billableEvents += 1;
125
+ }
126
+ previousTotal = total;
127
+ if (total > bestTotal) {
128
+ bestTotal = total;
129
+ best = {
130
+ input: num(cumulative.input_tokens),
131
+ output: num(cumulative.output_tokens),
132
+ cached: num(cumulative.cached_input_tokens),
133
+ };
134
+ }
135
+ }
136
+ }
137
+ finally {
138
+ rl.close();
139
+ }
140
+ if (!best) {
141
+ return null;
142
+ }
143
+ return { model, billableEvents, ...best };
144
+ }
145
+ export async function createCodexReader() {
146
+ return {
147
+ descriptor: {
148
+ id: CLI_ID,
149
+ displayName: "Codex",
150
+ verified: true,
151
+ dedupStrategy: "session-dag",
152
+ costConfidence: "unavailable",
153
+ requiresSqlite: false,
154
+ },
155
+ detect: async () => {
156
+ try {
157
+ const info = await stat(sessionsRoot());
158
+ return info.isDirectory();
159
+ }
160
+ catch {
161
+ return false;
162
+ }
163
+ },
164
+ scan: async (options) => {
165
+ const totals = emptyTotals();
166
+ const errors = [];
167
+ const models = new Set();
168
+ const files = [];
169
+ await collectRollouts(sessionsRoot(), files);
170
+ const sinceDays = options?.sinceDays ?? DEFAULT_SINCE_DAYS;
171
+ const cutoff = Number.isFinite(sinceDays) && sinceDays > 0
172
+ ? Date.now() - sinceDays * 86_400_000
173
+ : undefined;
174
+ let filesScanned = 0;
175
+ for (const file of files) {
176
+ try {
177
+ if (cutoff !== undefined) {
178
+ const info = await stat(file);
179
+ if (info.mtimeMs < cutoff) {
180
+ continue;
181
+ }
182
+ }
183
+ const rollup = await readRollout(file);
184
+ filesScanned += 1;
185
+ if (!rollup) {
186
+ continue;
187
+ }
188
+ totals.requests += rollup.billableEvents;
189
+ // `cached_input_tokens` is a SUBSET of `input_tokens` here — verified
190
+ // on all 108 sessions, where input + output == total exactly and
191
+ // cached <= input always. The other readers keep the two disjoint, so
192
+ // the cached portion is subtracted out rather than reported twice; a
193
+ // caller adding inputTokens + cacheReadTokens would otherwise
194
+ // over-count Codex and not the rest.
195
+ totals.inputTokens += Math.max(0, rollup.input - rollup.cached);
196
+ totals.cacheReadTokens += rollup.cached;
197
+ totals.outputTokens += rollup.output;
198
+ if (rollup.model) {
199
+ models.add(rollup.model);
200
+ }
201
+ }
202
+ catch (error) {
203
+ errors.push({
204
+ cliId: CLI_ID,
205
+ filePath: file,
206
+ message: error instanceof Error ? error.message : String(error),
207
+ });
208
+ }
209
+ }
210
+ // Every turn is unpriced by construction, not by accident: see the note
211
+ // on this module about Codex being a subscription. Naming the models
212
+ // keeps that legible instead of looking like a lookup that failed.
213
+ totals.unpricedRequests = totals.requests;
214
+ totals.unpricedModels = [...models].sort();
215
+ return { cliId: CLI_ID, totals, filesScanned, errors };
216
+ },
217
+ };
218
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Local usage: token spend read from each CLI's own session logs.
3
+ *
4
+ * The proxy's ledger can only account for traffic that went through it, which
5
+ * caps coverage at the CLIs that expose a base-URL override. Every CLI writes
6
+ * a local transcript regardless, so reading those covers the rest — and covers
7
+ * history from before the proxy existed.
8
+ */
9
+ import type { LocalUsageAggregateReport, LocalUsageScanOptions } from "../types/index.js";
10
+ export { createLocalUsageReader, getLocalUsageDescriptors, getRegisteredLocalUsageCliIds, registerLocalUsageReader, } from "./localUsageReaderRegistry.js";
11
+ /**
12
+ * Scan every registered reader whose CLI is actually present on this machine.
13
+ *
14
+ * "Not installed" and "failed" are reported separately and deliberately: a CLI
15
+ * the user never installed is not an error, and collapsing the two would make
16
+ * a broken reader indistinguishable from an absent one.
17
+ */
18
+ export declare function readAllLocalUsage(options?: LocalUsageScanOptions): Promise<LocalUsageAggregateReport>;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Local usage: token spend read from each CLI's own session logs.
3
+ *
4
+ * The proxy's ledger can only account for traffic that went through it, which
5
+ * caps coverage at the CLIs that expose a base-URL override. Every CLI writes
6
+ * a local transcript regardless, so reading those covers the rest — and covers
7
+ * history from before the proxy existed.
8
+ */
9
+ import { createLocalUsageReader, getRegisteredLocalUsageCliIds, } from "./localUsageReaderRegistry.js";
10
+ export { createLocalUsageReader, getLocalUsageDescriptors, getRegisteredLocalUsageCliIds, registerLocalUsageReader, } from "./localUsageReaderRegistry.js";
11
+ /**
12
+ * Scan every registered reader whose CLI is actually present on this machine.
13
+ *
14
+ * "Not installed" and "failed" are reported separately and deliberately: a CLI
15
+ * the user never installed is not an error, and collapsing the two would make
16
+ * a broken reader indistinguishable from an absent one.
17
+ */
18
+ export async function readAllLocalUsage(options) {
19
+ const totals = {};
20
+ const failures = [];
21
+ const notInstalled = [];
22
+ for (const cliId of getRegisteredLocalUsageCliIds()) {
23
+ try {
24
+ const reader = await createLocalUsageReader(cliId);
25
+ if (!(await reader.detect())) {
26
+ notInstalled.push(cliId);
27
+ continue;
28
+ }
29
+ const result = await reader.scan(options);
30
+ totals[cliId] = result.totals;
31
+ }
32
+ catch (error) {
33
+ // One reader throwing must not lose the others' results.
34
+ failures.push({
35
+ cliId,
36
+ message: error instanceof Error ? error.message : String(error),
37
+ });
38
+ }
39
+ }
40
+ return {
41
+ generatedAt: new Date().toISOString(),
42
+ totals,
43
+ failures,
44
+ notInstalled,
45
+ };
46
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Registry of local-usage readers, one per CLI.
3
+ *
4
+ * Mirrors `providerRegistry.ts`: descriptors are static and always available,
5
+ * while the reader itself arrives through a dynamic import inside its factory.
6
+ * Same reason as the provider registry — a static import graph across a dozen
7
+ * readers is how circular dependencies start, and it would also force every
8
+ * reader's cost of loading onto a caller who only wanted one of them.
9
+ */
10
+ import type { LocalUsageCliId, LocalUsageReader, LocalUsageReaderDescriptor, LocalUsageReaderRegistration } from "../types/index.js";
11
+ export declare function registerLocalUsageReader(registration: LocalUsageReaderRegistration): void;
12
+ export declare function getLocalUsageDescriptors(): LocalUsageReaderDescriptor[];
13
+ export declare function getRegisteredLocalUsageCliIds(): LocalUsageCliId[];
14
+ export declare function createLocalUsageReader(cliId: LocalUsageCliId): Promise<LocalUsageReader>;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Registry of local-usage readers, one per CLI.
3
+ *
4
+ * Mirrors `providerRegistry.ts`: descriptors are static and always available,
5
+ * while the reader itself arrives through a dynamic import inside its factory.
6
+ * Same reason as the provider registry — a static import graph across a dozen
7
+ * readers is how circular dependencies start, and it would also force every
8
+ * reader's cost of loading onto a caller who only wanted one of them.
9
+ */
10
+ const registry = new Map();
11
+ export function registerLocalUsageReader(registration) {
12
+ registry.set(registration.descriptor.id, registration);
13
+ }
14
+ export function getLocalUsageDescriptors() {
15
+ return [...registry.values()].map((entry) => entry.descriptor);
16
+ }
17
+ export function getRegisteredLocalUsageCliIds() {
18
+ return [...registry.keys()];
19
+ }
20
+ export async function createLocalUsageReader(cliId) {
21
+ const registration = registry.get(cliId);
22
+ if (!registration) {
23
+ throw new Error(`No local-usage reader registered for "${cliId}". Registered: ${getRegisteredLocalUsageCliIds().join(", ") || "(none)"}`);
24
+ }
25
+ return registration.factory();
26
+ }
27
+ registerLocalUsageReader({
28
+ descriptor: {
29
+ id: "claude-code",
30
+ displayName: "Claude Code",
31
+ verified: true,
32
+ dedupStrategy: "message-id-keep-max",
33
+ costConfidence: "modeled",
34
+ requiresSqlite: false,
35
+ },
36
+ factory: async () => {
37
+ const { createClaudeCodeReader } = await import("./claudeCodeReader.js");
38
+ return createClaudeCodeReader();
39
+ },
40
+ });
41
+ registerLocalUsageReader({
42
+ descriptor: {
43
+ id: "codex",
44
+ displayName: "Codex",
45
+ verified: true,
46
+ dedupStrategy: "session-dag",
47
+ costConfidence: "unavailable",
48
+ requiresSqlite: false,
49
+ },
50
+ factory: async () => {
51
+ const { createCodexReader } = await import("./codexReader.js");
52
+ return createCodexReader();
53
+ },
54
+ });
@@ -84,3 +84,4 @@ export * from "./modelPool.js";
84
84
  export * from "./requestRouter.js";
85
85
  export * from "./classifierRouter.js";
86
86
  export * from "./agentNetwork.js";
87
+ export * from "./localUsage.js";
@@ -95,3 +95,4 @@ export * from "./requestRouter.js";
95
95
  export * from "./classifierRouter.js";
96
96
  // Multi-Agent orchestration types
97
97
  export * from "./agentNetwork.js";
98
+ export * from "./localUsage.js";