codex-usage-analyzer 0.1.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,363 @@
1
+ import { constants } from "node:fs";
2
+ import { access, readdir, readFile, stat } from "node:fs/promises";
3
+ import { extname, isAbsolute, join, normalize } from "node:path";
4
+
5
+ import { resolveCodexHome } from "./codex-home.js";
6
+
7
+ export const BUILT_IN_CODEX_PETS = Object.freeze([
8
+ { id: "codex", displayName: "Codex" },
9
+ { id: "dewey", displayName: "Dewey" },
10
+ { id: "fireball", displayName: "Fireball" },
11
+ { id: "hoots", displayName: "Hoots" },
12
+ { id: "rocky", displayName: "Rocky" },
13
+ { id: "seedy", displayName: "Seedy" },
14
+ { id: "stacky", displayName: "Stacky" },
15
+ { id: "bsod", displayName: "BSOD" },
16
+ { id: "null-signal", displayName: "Null Signal" }
17
+ ]);
18
+
19
+ const DEFAULT_BUILT_IN_PET_ID = "codex";
20
+ const CUSTOM_AVATAR_PREFIX = "custom:";
21
+ const STATE_FILE_NAME = ".codex-global-state.json";
22
+ const PERSISTED_ATOM_KEY = "electron-persisted-atom-state";
23
+ const SELECTED_AVATAR_KEY = "selected-avatar-id";
24
+ const CUSTOM_PET_MANIFESTS = ["pet.json", "avatar.json"];
25
+ const GENERATED_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp"]);
26
+ const CONTENT_TYPES_BY_EXTENSION = new Map([
27
+ [".png", "image/png"],
28
+ [".jpg", "image/jpeg"],
29
+ [".jpeg", "image/jpeg"],
30
+ [".webp", "image/webp"]
31
+ ]);
32
+
33
+ export async function aggregateCodexAssetsFromCodexHome(options = {}) {
34
+ const { codexHome, source } = resolveCodexHome(options);
35
+ const [customPets, selectedAvatar, generatedImages] = await Promise.all([
36
+ discoverCustomPets(codexHome),
37
+ readSelectedAvatarId(codexHome),
38
+ countExcludedGeneratedImages(codexHome)
39
+ ]);
40
+ const selectedPet = resolveSelectedPet(selectedAvatar.value, customPets.items);
41
+ const petAsset = createPetAsset(selectedPet);
42
+ const codexAssets = {
43
+ avatar: null,
44
+ pet: petAsset
45
+ };
46
+
47
+ return {
48
+ codexAssets,
49
+ diagnostics: {
50
+ status: "ok",
51
+ reason: null,
52
+ source,
53
+ basis: "codex_desktop_pet_catalog_best_effort",
54
+ avatar: {
55
+ status: "unavailable",
56
+ reason: "avatar_source_not_owned"
57
+ },
58
+ pet: {
59
+ status: "ok",
60
+ reason: selectedPet.reason,
61
+ kind: selectedPet.kind,
62
+ selectedSource: selectedAvatar.source,
63
+ selectedId: selectedPet.kind === "builtin" ? selectedPet.id : null,
64
+ selectedIdRedacted: selectedPet.kind === "custom",
65
+ assetRef: petAsset.assetRef,
66
+ contentType: petAsset.contentType
67
+ },
68
+ selectedAvatar: {
69
+ status: selectedAvatar.status,
70
+ reason: selectedAvatar.reason,
71
+ source: selectedAvatar.source
72
+ },
73
+ builtInPetCount: BUILT_IN_CODEX_PETS.length,
74
+ customPetCount: customPets.items.length,
75
+ customPetDiagnostics: customPets.diagnostics,
76
+ excludedCandidateCount: generatedImages.count,
77
+ excludedSources: generatedImages.count > 0 ? ["generated_images"] : []
78
+ }
79
+ };
80
+ }
81
+
82
+ async function readSelectedAvatarId(codexHome) {
83
+ let content;
84
+ try {
85
+ content = await readFile(join(codexHome, STATE_FILE_NAME), "utf8");
86
+ } catch {
87
+ return {
88
+ status: "unavailable",
89
+ reason: "selected_avatar_state_not_found",
90
+ source: "default",
91
+ value: null
92
+ };
93
+ }
94
+
95
+ let state;
96
+ try {
97
+ state = JSON.parse(content);
98
+ } catch {
99
+ return {
100
+ status: "unavailable",
101
+ reason: "selected_avatar_state_unreadable",
102
+ source: "default",
103
+ value: null
104
+ };
105
+ }
106
+
107
+ const persistedState = isRecord(state[PERSISTED_ATOM_KEY])
108
+ ? state[PERSISTED_ATOM_KEY]
109
+ : state;
110
+ const value = persistedState[SELECTED_AVATAR_KEY];
111
+
112
+ if (typeof value === "string" && value.trim().length > 0) {
113
+ return {
114
+ status: "ok",
115
+ reason: null,
116
+ source: "persisted_atom",
117
+ value: value.trim()
118
+ };
119
+ }
120
+
121
+ return {
122
+ status: "unavailable",
123
+ reason: "selected_avatar_id_not_set",
124
+ source: "default",
125
+ value: null
126
+ };
127
+ }
128
+
129
+ function resolveSelectedPet(selectedAvatarId, customPets) {
130
+ if (typeof selectedAvatarId === "string") {
131
+ if (isBuiltInPetId(selectedAvatarId)) {
132
+ return {
133
+ kind: "builtin",
134
+ id: selectedAvatarId,
135
+ reason: null,
136
+ contentType: "image/webp"
137
+ };
138
+ }
139
+
140
+ if (selectedAvatarId.startsWith(CUSTOM_AVATAR_PREFIX)) {
141
+ const customId = selectedAvatarId.slice(CUSTOM_AVATAR_PREFIX.length);
142
+ const customPet = customPets.find((pet) => pet.id === customId);
143
+ if (customPet !== undefined) {
144
+ return {
145
+ kind: "custom",
146
+ id: null,
147
+ reason: null,
148
+ contentType: customPet.contentType
149
+ };
150
+ }
151
+ }
152
+
153
+ return {
154
+ kind: "builtin",
155
+ id: DEFAULT_BUILT_IN_PET_ID,
156
+ reason: "selected_avatar_not_found_fallback",
157
+ contentType: "image/webp"
158
+ };
159
+ }
160
+
161
+ return {
162
+ kind: "builtin",
163
+ id: DEFAULT_BUILT_IN_PET_ID,
164
+ reason: "default_selected_avatar",
165
+ contentType: "image/webp"
166
+ };
167
+ }
168
+
169
+ function createPetAsset(selectedPet) {
170
+ return {
171
+ kind: "codex-asset",
172
+ url: null,
173
+ assetRef: selectedPet.kind === "builtin"
174
+ ? `codex-built-in:pet:${selectedPet.id}`
175
+ : "codex-local:pet:custom-selected",
176
+ contentType: selectedPet.contentType
177
+ };
178
+ }
179
+
180
+ async function discoverCustomPets(codexHome) {
181
+ const petsDir = join(codexHome, "pets");
182
+ const diagnostics = {
183
+ status: "unavailable",
184
+ reason: "custom_pet_directory_not_found",
185
+ directoriesScanned: 0,
186
+ manifestFilesRead: 0,
187
+ validCustomPets: 0,
188
+ invalidCustomPets: 0,
189
+ nonDirectoryEntries: 0
190
+ };
191
+
192
+ let entries;
193
+ try {
194
+ entries = await readdir(petsDir, { withFileTypes: true });
195
+ } catch {
196
+ return {
197
+ diagnostics,
198
+ items: []
199
+ };
200
+ }
201
+
202
+ diagnostics.status = "ok";
203
+ diagnostics.reason = null;
204
+
205
+ const items = [];
206
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
207
+ if (!entry.isDirectory()) {
208
+ diagnostics.nonDirectoryEntries += 1;
209
+ continue;
210
+ }
211
+
212
+ diagnostics.directoriesScanned += 1;
213
+ const result = await readCustomPetDirectory(petsDir, entry.name);
214
+ if (result === null) {
215
+ diagnostics.invalidCustomPets += 1;
216
+ continue;
217
+ }
218
+
219
+ diagnostics.manifestFilesRead += 1;
220
+ diagnostics.validCustomPets += 1;
221
+ items.push(result);
222
+ }
223
+
224
+ return {
225
+ diagnostics,
226
+ items
227
+ };
228
+ }
229
+
230
+ async function readCustomPetDirectory(petsDir, entryName) {
231
+ const petDir = join(petsDir, entryName);
232
+
233
+ for (const manifestName of CUSTOM_PET_MANIFESTS) {
234
+ const manifest = await readCustomPetManifest(join(petDir, manifestName));
235
+ if (manifest === null) {
236
+ continue;
237
+ }
238
+
239
+ const spritesheetPath = normalizeSpritesheetPath(manifest.spritesheetPath);
240
+ if (spritesheetPath === null) {
241
+ return null;
242
+ }
243
+
244
+ const contentType = inferContentType(spritesheetPath);
245
+ if (contentType === null) {
246
+ return null;
247
+ }
248
+
249
+ if (!await isReadableFile(join(petDir, spritesheetPath))) {
250
+ return null;
251
+ }
252
+
253
+ return {
254
+ id: entryName,
255
+ contentType
256
+ };
257
+ }
258
+
259
+ return null;
260
+ }
261
+
262
+ async function readCustomPetManifest(manifestPath) {
263
+ let content;
264
+ try {
265
+ content = await readFile(manifestPath, "utf8");
266
+ } catch {
267
+ return null;
268
+ }
269
+
270
+ try {
271
+ const manifest = JSON.parse(content);
272
+ return isRecord(manifest) ? manifest : null;
273
+ } catch {
274
+ return null;
275
+ }
276
+ }
277
+
278
+ function normalizeSpritesheetPath(value) {
279
+ const path = typeof value === "string" && value.trim().length > 0
280
+ ? value.trim()
281
+ : "spritesheet.webp";
282
+ const normalized = normalize(path);
283
+
284
+ if (
285
+ isAbsolute(normalized)
286
+ || normalized === "."
287
+ || normalized.startsWith("..")
288
+ || normalized.includes("/../")
289
+ || normalized.includes("\\..\\")
290
+ ) {
291
+ return null;
292
+ }
293
+
294
+ return normalized;
295
+ }
296
+
297
+ async function countExcludedGeneratedImages(codexHome) {
298
+ const generatedImagesDir = join(codexHome, "generated_images");
299
+
300
+ if (!await isReadableDirectory(generatedImagesDir)) {
301
+ return { count: 0 };
302
+ }
303
+
304
+ return {
305
+ count: await countFilesByExtension(generatedImagesDir, GENERATED_IMAGE_EXTENSIONS)
306
+ };
307
+ }
308
+
309
+ async function countFilesByExtension(directory, extensions) {
310
+ let entries;
311
+ try {
312
+ entries = await readdir(directory, { withFileTypes: true });
313
+ } catch {
314
+ return 0;
315
+ }
316
+
317
+ let count = 0;
318
+ for (const entry of entries) {
319
+ const entryPath = join(directory, entry.name);
320
+ if (entry.isDirectory()) {
321
+ count += await countFilesByExtension(entryPath, extensions);
322
+ } else if (entry.isFile() && extensions.has(extname(entry.name).toLowerCase())) {
323
+ count += 1;
324
+ }
325
+ }
326
+
327
+ return count;
328
+ }
329
+
330
+ async function isReadableFile(path) {
331
+ try {
332
+ const stats = await stat(path);
333
+ if (!stats.isFile()) {
334
+ return false;
335
+ }
336
+
337
+ await access(path, constants.R_OK);
338
+ return true;
339
+ } catch {
340
+ return false;
341
+ }
342
+ }
343
+
344
+ async function isReadableDirectory(path) {
345
+ try {
346
+ const stats = await stat(path);
347
+ return stats.isDirectory();
348
+ } catch {
349
+ return false;
350
+ }
351
+ }
352
+
353
+ function inferContentType(path) {
354
+ return CONTENT_TYPES_BY_EXTENSION.get(extname(path).toLowerCase()) ?? null;
355
+ }
356
+
357
+ function isBuiltInPetId(value) {
358
+ return BUILT_IN_CODEX_PETS.some((pet) => pet.id === value);
359
+ }
360
+
361
+ function isRecord(value) {
362
+ return value !== null && typeof value === "object" && !Array.isArray(value);
363
+ }
@@ -0,0 +1,37 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ export function resolveCodexHome(options = {}, env = process.env) {
5
+ const explicitHome = normalizeCodexHome(options.codexHome);
6
+ if (explicitHome !== null) {
7
+ return {
8
+ codexHome: explicitHome,
9
+ source: "option"
10
+ };
11
+ }
12
+
13
+ const envHome = normalizeCodexHome(env.CODEX_HOME);
14
+ if (envHome !== null) {
15
+ return {
16
+ codexHome: envHome,
17
+ source: "env"
18
+ };
19
+ }
20
+
21
+ return {
22
+ codexHome: join(homedir(), ".codex"),
23
+ source: "default"
24
+ };
25
+ }
26
+
27
+ function normalizeCodexHome(value) {
28
+ if (value === undefined || value === null) {
29
+ return null;
30
+ }
31
+
32
+ if (typeof value !== "string" || value.trim().length === 0) {
33
+ throw new TypeError("codexHome must be a non-empty string");
34
+ }
35
+
36
+ return value;
37
+ }
@@ -0,0 +1,204 @@
1
+ import { resolveCodexHome } from "./codex-home.js";
2
+ import {
3
+ discoverSessionJsonlFiles,
4
+ extractSessionModel,
5
+ normalizeSessionTokenCountEvent,
6
+ readSessionJsonlEntries
7
+ } from "./session-jsonl.js";
8
+ import {
9
+ addTokenUsageBreakdown,
10
+ createNullableTokenBreakdownState,
11
+ finalizeTokenBreakdown,
12
+ getTokenUsageTotal
13
+ } from "./token-aggregate.js";
14
+
15
+ export async function aggregateModelUsageFromCodexHome(options = {}) {
16
+ const { codexHome, source } = resolveCodexHome(options);
17
+ const discovery = await discoverSessionJsonlFiles(codexHome);
18
+ const aggregate = await aggregateModelUsageFromSessionFiles(discovery.files);
19
+
20
+ aggregate.diagnostics.source = source;
21
+ aggregate.diagnostics.discovery = summarizeDiscovery(discovery.diagnostics);
22
+
23
+ if (discovery.files.length === 0) {
24
+ aggregate.diagnostics.status = "unavailable";
25
+ aggregate.diagnostics.reason = "session_jsonl_not_found";
26
+ }
27
+
28
+ return aggregate;
29
+ }
30
+
31
+ export async function aggregateModelUsageFromSessionFiles(files) {
32
+ const state = createAggregateState();
33
+
34
+ for await (const entry of readSessionJsonlEntries(files)) {
35
+ updateFileContext(state, entry);
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 eventModel = extractSessionModel(entry.event);
49
+ const tokenEvent = normalizeSessionTokenCountEvent(entry.event);
50
+ if (tokenEvent === null) {
51
+ if (eventModel !== null) {
52
+ state.currentModel = eventModel;
53
+ state.diagnostics.modelContextEvents += 1;
54
+ } else {
55
+ state.diagnostics.ignoredEvents += 1;
56
+ }
57
+ continue;
58
+ }
59
+
60
+ state.diagnostics.tokenEvents += 1;
61
+
62
+ const directModel = readNonEmptyString(tokenEvent.model);
63
+ const model = directModel ?? state.currentModel;
64
+ if (model === null) {
65
+ state.diagnostics.tokenEventsWithoutModel += 1;
66
+ continue;
67
+ }
68
+
69
+ state.currentModel = model;
70
+ if (directModel === null) {
71
+ state.diagnostics.tokenEventsWithInheritedModel += 1;
72
+ }
73
+ state.diagnostics.tokenEventsWithModel += 1;
74
+ applyModelEvent(state, model, tokenEvent);
75
+ }
76
+
77
+ return finalizeAggregate(files.length, state);
78
+ }
79
+
80
+ function createAggregateState() {
81
+ return {
82
+ currentFile: null,
83
+ currentModel: null,
84
+ diagnostics: {
85
+ status: "unavailable",
86
+ reason: "no_model_events",
87
+ source: "session_jsonl",
88
+ filesScanned: 0,
89
+ entriesScanned: 0,
90
+ ignoredEvents: 0,
91
+ modelContextEvents: 0,
92
+ tokenEvents: 0,
93
+ tokenEventsWithModel: 0,
94
+ tokenEventsWithInheritedModel: 0,
95
+ tokenEventsWithoutModel: 0,
96
+ tokenEventsWithUsage: 0,
97
+ malformedLines: 0,
98
+ fileErrors: 0,
99
+ discovery: []
100
+ },
101
+ models: new Map()
102
+ };
103
+ }
104
+
105
+ function updateFileContext(state, entry) {
106
+ if (entry.file === undefined || entry.file === state.currentFile) {
107
+ return;
108
+ }
109
+
110
+ state.currentFile = entry.file;
111
+ state.currentModel = null;
112
+ }
113
+
114
+ function applyModelEvent(state, model, tokenEvent) {
115
+ const item = getModelUsageState(state.models, model);
116
+ item.usageCount += 1;
117
+
118
+ if (tokenEvent.lastTokenUsage === null) {
119
+ return;
120
+ }
121
+
122
+ const totalTokens = getTokenUsageTotal(tokenEvent.lastTokenUsage);
123
+ if (totalTokens === null) {
124
+ return;
125
+ }
126
+
127
+ item.hasTokens = true;
128
+ item.totalTokens += totalTokens;
129
+ state.diagnostics.tokenEventsWithUsage += 1;
130
+ addTokenUsageBreakdown(item.breakdown, tokenEvent.lastTokenUsage);
131
+ }
132
+
133
+ function finalizeAggregate(filesScanned, state) {
134
+ const items = Array.from(state.models.values())
135
+ .map(finalizeModelUsage)
136
+ .sort(compareModelUsage);
137
+
138
+ const hasModels = items.length > 0;
139
+ state.diagnostics.filesScanned = filesScanned;
140
+ state.diagnostics.status = hasModels ? "ok" : state.diagnostics.status;
141
+ state.diagnostics.reason = hasModels ? null : state.diagnostics.reason;
142
+
143
+ return {
144
+ diagnostics: state.diagnostics,
145
+ models: {
146
+ favoriteModel: items[0] ?? null,
147
+ items
148
+ }
149
+ };
150
+ }
151
+
152
+ function getModelUsageState(models, model) {
153
+ const existing = models.get(model);
154
+ if (existing !== undefined) {
155
+ return existing;
156
+ }
157
+
158
+ const item = {
159
+ breakdown: createNullableTokenBreakdownState(),
160
+ hasTokens: false,
161
+ model,
162
+ totalTokens: 0,
163
+ usageCount: 0
164
+ };
165
+ models.set(model, item);
166
+ return item;
167
+ }
168
+
169
+ function finalizeModelUsage(item) {
170
+ return {
171
+ model: item.model,
172
+ displayName: null,
173
+ totalTokens: item.hasTokens ? item.totalTokens : null,
174
+ usageCount: item.usageCount,
175
+ basis: item.hasTokens ? "tokens" : "usage_count",
176
+ ...finalizeTokenBreakdown(item.breakdown)
177
+ };
178
+ }
179
+
180
+ function compareModelUsage(left, right) {
181
+ const tokenDiff = (right.totalTokens ?? -1) - (left.totalTokens ?? -1);
182
+ if (tokenDiff !== 0) return tokenDiff;
183
+
184
+ const countDiff = (right.usageCount ?? -1) - (left.usageCount ?? -1);
185
+ if (countDiff !== 0) return countDiff;
186
+
187
+ return left.model.localeCompare(right.model);
188
+ }
189
+
190
+ function readNonEmptyString(value) {
191
+ if (typeof value !== "string") {
192
+ return null;
193
+ }
194
+
195
+ const trimmed = value.trim();
196
+ return trimmed.length > 0 ? trimmed : null;
197
+ }
198
+
199
+ function summarizeDiscovery(diagnostics) {
200
+ return diagnostics.map((diagnostic) => ({
201
+ code: diagnostic.code,
202
+ severity: diagnostic.severity
203
+ }));
204
+ }