codex-usage-analyzer 0.1.0 → 0.2.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.
@@ -1,231 +0,0 @@
1
- import { createReadStream } from "node:fs";
2
- import { access, readdir } from "node:fs/promises";
3
- import { constants } from "node:fs";
4
- import { createInterface } from "node:readline";
5
- import { join } from "node:path";
6
-
7
- export async function discoverSessionJsonlFiles(codexHome) {
8
- const sessionsDir = join(codexHome, "sessions");
9
- const diagnostics = [];
10
-
11
- try {
12
- await access(sessionsDir, constants.R_OK);
13
- } catch {
14
- return {
15
- files: [],
16
- diagnostics: [{
17
- code: "sessions_unavailable",
18
- severity: "info"
19
- }]
20
- };
21
- }
22
-
23
- const files = [];
24
- await collectJsonlFiles(sessionsDir, files, diagnostics);
25
-
26
- return {
27
- files: files.sort(),
28
- diagnostics
29
- };
30
- }
31
-
32
- export async function* readSessionJsonlEntries(files) {
33
- for (const file of files) {
34
- let lineNumber = 0;
35
-
36
- let lines;
37
- try {
38
- lines = createInterface({
39
- crlfDelay: Infinity,
40
- input: createReadStream(file, { encoding: "utf8" })
41
- });
42
- } catch {
43
- yield { kind: "file_error" };
44
- continue;
45
- }
46
-
47
- try {
48
- for await (const line of lines) {
49
- lineNumber += 1;
50
-
51
- if (line.trim().length === 0) {
52
- continue;
53
- }
54
-
55
- try {
56
- yield {
57
- file,
58
- kind: "event",
59
- event: JSON.parse(line)
60
- };
61
- } catch {
62
- yield {
63
- file,
64
- kind: "malformed_line",
65
- lineNumber
66
- };
67
- }
68
- }
69
- } catch {
70
- yield { kind: "file_error" };
71
- }
72
- }
73
- }
74
-
75
- export function normalizeSessionTokenCountEvent(event) {
76
- if (!isRecord(event) || event.type !== "event_msg") {
77
- return null;
78
- }
79
-
80
- const payload = event.payload;
81
- if (!isRecord(payload) || payload.type !== "token_count") {
82
- return null;
83
- }
84
-
85
- const info = isRecord(payload.info) ? payload.info : null;
86
-
87
- return {
88
- durationMs: payload.duration_ms,
89
- effort: payload.effort,
90
- lastTokenUsage: readRecordAlias(payload, info, "last_token_usage"),
91
- mode: payload.mode,
92
- model: extractSessionModel(event),
93
- timestamp: event.timestamp,
94
- totalTokenUsage: readRecordAlias(payload, info, "total_token_usage")
95
- };
96
- }
97
-
98
- export function normalizeSessionToolCatalogEvent(event) {
99
- if (!isRecord(event) || event.type !== "session_meta") {
100
- return null;
101
- }
102
-
103
- const payload = isRecord(event.payload) ? event.payload : null;
104
- if (payload === null || !Array.isArray(payload.dynamic_tools)) {
105
- return null;
106
- }
107
-
108
- const items = [];
109
- for (const item of payload.dynamic_tools) {
110
- if (!isRecord(item)) {
111
- continue;
112
- }
113
-
114
- const name = readStringAlias(item.name);
115
- if (name === null) {
116
- continue;
117
- }
118
-
119
- items.push({
120
- name,
121
- namespace: readStringAlias(item.namespace)
122
- });
123
- }
124
-
125
- return items.length > 0 ? { items } : null;
126
- }
127
-
128
- export function normalizeSessionToolInvocationEvent(event) {
129
- if (!isRecord(event)) {
130
- return null;
131
- }
132
-
133
- const payload = isRecord(event.payload) ? event.payload : null;
134
- if (payload === null) {
135
- return null;
136
- }
137
-
138
- if (
139
- event.type === "response_item"
140
- && (payload.type === "function_call" || payload.type === "custom_tool_call")
141
- ) {
142
- const name = readStringAlias(payload.name);
143
- return name === null ? null : {
144
- kind: "named_call",
145
- name,
146
- namespace: null
147
- };
148
- }
149
-
150
- if (event.type === "event_msg" && payload.type === "dynamic_tool_call_request") {
151
- const name = readStringAlias(payload.tool);
152
- return name === null ? null : {
153
- kind: "dynamic_request",
154
- name,
155
- namespace: readStringAlias(payload.namespace)
156
- };
157
- }
158
-
159
- return null;
160
- }
161
-
162
- export function extractSessionModel(event) {
163
- if (!isRecord(event)) {
164
- return null;
165
- }
166
-
167
- const payload = isRecord(event.payload) ? event.payload : null;
168
- if (payload === null) {
169
- return null;
170
- }
171
-
172
- const info = isRecord(payload.info) ? payload.info : null;
173
- const modelInfo = isRecord(payload.model_info) ? payload.model_info : null;
174
-
175
- return readStringAlias(
176
- modelInfo?.slug,
177
- payload.model,
178
- payload.model_name,
179
- info?.model,
180
- info?.model_name
181
- );
182
- }
183
-
184
- async function collectJsonlFiles(directory, files, diagnostics) {
185
- let entries;
186
- try {
187
- entries = await readdir(directory, { withFileTypes: true });
188
- } catch {
189
- diagnostics.push({
190
- code: "session_directory_unreadable",
191
- severity: "warning"
192
- });
193
- return;
194
- }
195
-
196
- for (const entry of entries) {
197
- const entryPath = join(directory, entry.name);
198
-
199
- if (entry.isDirectory()) {
200
- await collectJsonlFiles(entryPath, files, diagnostics);
201
- } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
202
- files.push(entryPath);
203
- }
204
- }
205
- }
206
-
207
- function isRecord(value) {
208
- return value !== null && typeof value === "object" && !Array.isArray(value);
209
- }
210
-
211
- function readRecordAlias(primary, secondary, key) {
212
- if (isRecord(primary[key])) {
213
- return primary[key];
214
- }
215
-
216
- if (isRecord(secondary?.[key])) {
217
- return secondary[key];
218
- }
219
-
220
- return null;
221
- }
222
-
223
- function readStringAlias(...values) {
224
- for (const value of values) {
225
- if (typeof value === "string" && value.trim().length > 0) {
226
- return value;
227
- }
228
- }
229
-
230
- return null;
231
- }
@@ -1,228 +0,0 @@
1
- import { resolveCodexHome } from "./codex-home.js";
2
- import {
3
- discoverSessionJsonlFiles,
4
- normalizeSessionToolCatalogEvent,
5
- normalizeSessionToolInvocationEvent,
6
- readSessionJsonlEntries
7
- } from "./session-jsonl.js";
8
-
9
- const TOP_LIMIT = 10;
10
-
11
- export async function aggregateSkillPluginUsageFromCodexHome(options = {}) {
12
- const { codexHome, source } = resolveCodexHome(options);
13
- const discovery = await discoverSessionJsonlFiles(codexHome);
14
- const aggregate = await aggregateSkillPluginUsageFromSessionFiles(discovery.files);
15
-
16
- aggregate.diagnostics.source = source;
17
- aggregate.diagnostics.discovery = summarizeDiscovery(discovery.diagnostics);
18
-
19
- if (discovery.files.length === 0) {
20
- aggregate.diagnostics.status = "unavailable";
21
- aggregate.diagnostics.reason = "session_jsonl_not_found";
22
- }
23
-
24
- return aggregate;
25
- }
26
-
27
- export async function aggregateSkillPluginUsageFromSessionFiles(files) {
28
- const state = createAggregateState();
29
-
30
- for await (const entry of readSessionJsonlEntries(files)) {
31
- updateFileContext(state, entry);
32
- state.diagnostics.entriesScanned += 1;
33
-
34
- if (entry.kind === "malformed_line") {
35
- state.diagnostics.malformedLines += 1;
36
- continue;
37
- }
38
-
39
- if (entry.kind === "file_error") {
40
- state.diagnostics.fileErrors += 1;
41
- continue;
42
- }
43
-
44
- const catalog = normalizeSessionToolCatalogEvent(entry.event);
45
- if (catalog !== null) {
46
- state.diagnostics.catalogEvents += 1;
47
- applyCatalogEvent(state, catalog);
48
- continue;
49
- }
50
-
51
- const invocation = normalizeSessionToolInvocationEvent(entry.event);
52
- if (invocation === null) {
53
- state.diagnostics.ignoredEvents += 1;
54
- continue;
55
- }
56
-
57
- applyInvocationEvent(state, invocation);
58
- }
59
-
60
- return finalizeAggregate(files.length, state);
61
- }
62
-
63
- function createAggregateState() {
64
- return {
65
- currentCatalog: new Map(),
66
- currentFile: null,
67
- diagnostics: {
68
- status: "unavailable",
69
- reason: "no_skill_plugin_invocations",
70
- source: "session_jsonl",
71
- filesScanned: 0,
72
- entriesScanned: 0,
73
- ignoredEvents: 0,
74
- catalogEvents: 0,
75
- catalogItems: 0,
76
- actualInvocationEvents: 0,
77
- classifiedSkillInvocations: 0,
78
- classifiedPluginInvocations: 0,
79
- unclassifiedInvocations: 0,
80
- malformedLines: 0,
81
- fileErrors: 0,
82
- topLimit: TOP_LIMIT,
83
- classificationBasis: "actual_invocation_with_session_catalog",
84
- discovery: []
85
- },
86
- plugins: new Map(),
87
- skills: new Map()
88
- };
89
- }
90
-
91
- function updateFileContext(state, entry) {
92
- if (entry.file === undefined || entry.file === state.currentFile) {
93
- return;
94
- }
95
-
96
- state.currentCatalog = new Map();
97
- state.currentFile = entry.file;
98
- }
99
-
100
- function applyCatalogEvent(state, catalog) {
101
- for (const item of catalog.items) {
102
- const kind = item.namespace === null ? "skill" : "plugin";
103
- state.currentCatalog.set(item.name, {
104
- id: createRankingId(kind, item.name, item.namespace),
105
- kind,
106
- name: item.name
107
- });
108
- state.diagnostics.catalogItems += 1;
109
- }
110
- }
111
-
112
- function applyInvocationEvent(state, invocation) {
113
- state.diagnostics.actualInvocationEvents += 1;
114
-
115
- const catalogItem = state.currentCatalog.get(invocation.name);
116
- const classified = catalogItem ?? classifyStandaloneInvocation(invocation);
117
- if (classified === null) {
118
- state.diagnostics.unclassifiedInvocations += 1;
119
- return;
120
- }
121
-
122
- const target = classified.kind === "skill" ? state.skills : state.plugins;
123
- const item = getRankingState(target, classified);
124
- item.usageCount += 1;
125
-
126
- if (classified.kind === "skill") {
127
- state.diagnostics.classifiedSkillInvocations += 1;
128
- } else {
129
- state.diagnostics.classifiedPluginInvocations += 1;
130
- }
131
- }
132
-
133
- function classifyStandaloneInvocation(invocation) {
134
- if (invocation.kind !== "dynamic_request" || invocation.namespace === null) {
135
- return null;
136
- }
137
-
138
- return {
139
- id: createRankingId("plugin", invocation.name, invocation.namespace),
140
- kind: "plugin",
141
- name: invocation.name
142
- };
143
- }
144
-
145
- function createRankingId(kind, name, namespace) {
146
- if (kind === "plugin" && namespace !== null) {
147
- return `${namespace}/${name}`;
148
- }
149
-
150
- return name;
151
- }
152
-
153
- function getRankingState(items, classified) {
154
- const existing = items.get(classified.id);
155
- if (existing !== undefined) {
156
- return existing;
157
- }
158
-
159
- const item = {
160
- displayName: classified.name,
161
- id: classified.id,
162
- name: classified.name,
163
- usageCount: 0
164
- };
165
- items.set(item.id, item);
166
- return item;
167
- }
168
-
169
- function finalizeAggregate(filesScanned, state) {
170
- const topSkills = finalizeRanking(state.skills);
171
- const topPlugins = finalizeRanking(state.plugins);
172
- const hasClassifiedInvocations = topSkills.length > 0 || topPlugins.length > 0;
173
-
174
- state.diagnostics.filesScanned = filesScanned;
175
- state.diagnostics.status = hasClassifiedInvocations ? "ok" : state.diagnostics.status;
176
- state.diagnostics.reason = hasClassifiedInvocations ? null : state.diagnostics.reason;
177
-
178
- return {
179
- diagnostics: state.diagnostics,
180
- skills: {
181
- exploredCount: hasClassifiedInvocations ? state.skills.size : null,
182
- totalUsed: hasClassifiedInvocations ? state.diagnostics.classifiedSkillInvocations : null,
183
- topSkills
184
- },
185
- plugins: {
186
- topPlugins
187
- }
188
- };
189
- }
190
-
191
- function finalizeRanking(items) {
192
- return Array.from(items.values())
193
- .sort(compareRankingItem)
194
- .slice(0, TOP_LIMIT)
195
- .map((item) => ({
196
- id: item.id,
197
- name: item.name,
198
- displayName: item.displayName,
199
- usageCount: item.usageCount
200
- }));
201
- }
202
-
203
- function compareRankingItem(left, right) {
204
- const countDiff = right.usageCount - left.usageCount;
205
- if (countDiff !== 0) return countDiff;
206
-
207
- const displayNameDiff = compareNullableString(left.displayName, right.displayName);
208
- if (displayNameDiff !== 0) return displayNameDiff;
209
-
210
- const nameDiff = compareNullableString(left.name, right.name);
211
- if (nameDiff !== 0) return nameDiff;
212
-
213
- return left.id.localeCompare(right.id);
214
- }
215
-
216
- function compareNullableString(left, right) {
217
- if (left === null && right === null) return 0;
218
- if (left === null) return 1;
219
- if (right === null) return -1;
220
- return left.localeCompare(right);
221
- }
222
-
223
- function summarizeDiscovery(diagnostics) {
224
- return diagnostics.map((diagnostic) => ({
225
- code: diagnostic.code,
226
- severity: diagnostic.severity
227
- }));
228
- }
@@ -1,229 +0,0 @@
1
- import { resolveCodexHome } from "./codex-home.js";
2
- import {
3
- discoverSessionJsonlFiles,
4
- normalizeSessionTokenCountEvent,
5
- readSessionJsonlEntries
6
- } from "./session-jsonl.js";
7
-
8
- const TOKEN_FIELDS = [
9
- ["inputTokens", "input_tokens"],
10
- ["outputTokens", "output_tokens"],
11
- ["cacheReadTokens", "cached_input_tokens", "cache_read_input_tokens"],
12
- ["cacheWriteTokens", "cache_write_input_tokens", "cache_write_tokens"],
13
- ["reasoningTokens", "reasoning_output_tokens"]
14
- ];
15
-
16
- export async function aggregateTokenUsageFromCodexHome(options = {}) {
17
- const { codexHome, source } = resolveCodexHome(options);
18
- const discovery = await discoverSessionJsonlFiles(codexHome);
19
- const aggregate = await aggregateTokenUsageFromSessionFiles(discovery.files);
20
-
21
- aggregate.diagnostics.source = source;
22
- aggregate.diagnostics.discovery = summarizeDiscovery(discovery.diagnostics);
23
-
24
- if (discovery.files.length === 0) {
25
- aggregate.diagnostics.status = "unavailable";
26
- aggregate.diagnostics.reason = "session_jsonl_not_found";
27
- }
28
-
29
- return aggregate;
30
- }
31
-
32
- export async function aggregateTokenUsageFromSessionFiles(files) {
33
- const state = createAggregateState();
34
-
35
- for await (const entry of readSessionJsonlEntries(files)) {
36
- state.diagnostics.entriesScanned += 1;
37
-
38
- if (entry.kind === "malformed_line") {
39
- state.diagnostics.malformedLines += 1;
40
- continue;
41
- }
42
-
43
- if (entry.kind === "file_error") {
44
- state.diagnostics.fileErrors += 1;
45
- continue;
46
- }
47
-
48
- const tokenEvent = normalizeSessionTokenCountEvent(entry.event);
49
- if (tokenEvent === null) {
50
- state.diagnostics.ignoredEvents += 1;
51
- continue;
52
- }
53
-
54
- state.diagnostics.tokenEvents += 1;
55
-
56
- if (tokenEvent.lastTokenUsage === null) {
57
- state.diagnostics.tokenEventsWithoutLastUsage += 1;
58
- continue;
59
- }
60
-
61
- applyTokenEvent(state, tokenEvent);
62
- }
63
-
64
- return finalizeAggregate(files.length, state);
65
- }
66
-
67
- function createAggregateState() {
68
- return {
69
- breakdown: createNullableTokenBreakdownState(),
70
- daily: new Map(),
71
- diagnostics: {
72
- status: "unavailable",
73
- reason: "no_token_events",
74
- source: "session_jsonl",
75
- filesScanned: 0,
76
- entriesScanned: 0,
77
- ignoredEvents: 0,
78
- tokenEvents: 0,
79
- tokenEventsWithUsage: 0,
80
- tokenEventsWithoutLastUsage: 0,
81
- malformedLines: 0,
82
- fileErrors: 0,
83
- discovery: []
84
- },
85
- totalTokens: 0
86
- };
87
- }
88
-
89
- function applyTokenEvent(state, tokenEvent) {
90
- const totalTokens = getTokenUsageTotal(tokenEvent.lastTokenUsage);
91
- if (totalTokens === null) {
92
- state.diagnostics.tokenEventsWithoutLastUsage += 1;
93
- return;
94
- }
95
-
96
- state.totalTokens += totalTokens;
97
- state.diagnostics.tokenEventsWithUsage += 1;
98
- addTokenUsageBreakdown(state.breakdown, tokenEvent.lastTokenUsage);
99
-
100
- const date = getUtcDate(tokenEvent.timestamp);
101
- if (date !== null) {
102
- const daily = getDailyBucket(state.daily, date);
103
- daily.totalTokens += totalTokens;
104
- addTokenUsageBreakdown(daily.breakdown, tokenEvent.lastTokenUsage);
105
- }
106
- }
107
-
108
- function finalizeAggregate(filesScanned, state) {
109
- const daily = Array.from(state.daily.entries())
110
- .sort(([left], [right]) => left.localeCompare(right))
111
- .map(([date, bucket]) => ({
112
- date,
113
- totalTokens: bucket.totalTokens,
114
- ...finalizeTokenBreakdown(bucket.breakdown)
115
- }));
116
-
117
- const peakDailyTokens = daily.length > 0
118
- ? Math.max(...daily.map((bucket) => bucket.totalTokens))
119
- : null;
120
-
121
- const hasUsage = state.diagnostics.tokenEventsWithUsage > 0;
122
- state.diagnostics.filesScanned = filesScanned;
123
- state.diagnostics.status = hasUsage ? "ok" : state.diagnostics.status;
124
- state.diagnostics.reason = hasUsage ? null : state.diagnostics.reason;
125
-
126
- return {
127
- diagnostics: state.diagnostics,
128
- usage: {
129
- totalTokens: state.totalTokens,
130
- peakDailyTokens,
131
- tokenBreakdown: finalizeTokenBreakdown(state.breakdown),
132
- daily
133
- }
134
- };
135
- }
136
-
137
- export function getTokenUsageTotal(usage) {
138
- const explicitTotal = readNonNegativeInteger(usage.total_tokens);
139
- if (explicitTotal !== null) {
140
- return explicitTotal;
141
- }
142
-
143
- let total = 0;
144
- let hasAnyField = false;
145
- for (const [, ...sourceKeys] of TOKEN_FIELDS) {
146
- const value = readFirstNonNegativeInteger(usage, sourceKeys);
147
- if (value !== null) {
148
- total += value;
149
- hasAnyField = true;
150
- }
151
- }
152
-
153
- return hasAnyField ? total : null;
154
- }
155
-
156
- export function addTokenUsageBreakdown(target, usage) {
157
- for (const [targetKey, ...sourceKeys] of TOKEN_FIELDS) {
158
- const value = readFirstNonNegativeInteger(usage, sourceKeys);
159
- if (value !== null) {
160
- target[targetKey].seen = true;
161
- target[targetKey].value += value;
162
- }
163
- }
164
- }
165
-
166
- export function createNullableTokenBreakdownState() {
167
- return {
168
- inputTokens: { seen: false, value: 0 },
169
- outputTokens: { seen: false, value: 0 },
170
- cacheReadTokens: { seen: false, value: 0 },
171
- cacheWriteTokens: { seen: false, value: 0 },
172
- reasoningTokens: { seen: false, value: 0 }
173
- };
174
- }
175
-
176
- export function finalizeTokenBreakdown(state) {
177
- return Object.fromEntries(Object.entries(state).map(([key, entry]) => {
178
- return [key, entry.seen ? entry.value : null];
179
- }));
180
- }
181
-
182
- function getDailyBucket(buckets, date) {
183
- const existing = buckets.get(date);
184
- if (existing !== undefined) {
185
- return existing;
186
- }
187
-
188
- const bucket = {
189
- breakdown: createNullableTokenBreakdownState(),
190
- totalTokens: 0
191
- };
192
- buckets.set(date, bucket);
193
- return bucket;
194
- }
195
-
196
- export function getUtcDate(value) {
197
- if (typeof value !== "string") {
198
- return null;
199
- }
200
-
201
- const date = new Date(value);
202
- if (Number.isNaN(date.getTime())) {
203
- return null;
204
- }
205
-
206
- return date.toISOString().slice(0, 10);
207
- }
208
-
209
- function readFirstNonNegativeInteger(record, keys) {
210
- for (const key of keys) {
211
- const value = readNonNegativeInteger(record[key]);
212
- if (value !== null) {
213
- return value;
214
- }
215
- }
216
-
217
- return null;
218
- }
219
-
220
- function readNonNegativeInteger(value) {
221
- return Number.isInteger(value) && value >= 0 ? value : null;
222
- }
223
-
224
- function summarizeDiscovery(diagnostics) {
225
- return diagnostics.map((diagnostic) => ({
226
- code: diagnostic.code,
227
- severity: diagnostic.severity
228
- }));
229
- }
@@ -1,6 +0,0 @@
1
- export {
2
- USAGE_SNAPSHOT_V2_SCHEMA_VERSION,
3
- assertUsageSnapshotV2,
4
- isUsageSnapshotV2,
5
- validateUsageSnapshotV2
6
- } from "./v2-schema.js";