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,103 @@
1
+ import { USAGE_SNAPSHOT_V2_SCHEMA_VERSION } from "../snapshot/v2-schema.js";
2
+
3
+ export const sampleUsageSnapshotV2 = Object.freeze({
4
+ schemaVersion: USAGE_SNAPSHOT_V2_SCHEMA_VERSION,
5
+ capturedAt: "2026-06-12T00:00:00.000Z",
6
+ producer: {
7
+ name: "codex-usage-analyzer",
8
+ version: "0.1.0"
9
+ },
10
+ codexProfile: {
11
+ displayName: "postmelee",
12
+ username: "meleeisdeveloping",
13
+ planLabel: "Pro"
14
+ },
15
+ usage: {
16
+ totalTokens: 10300000000,
17
+ peakDailyTokens: 703000000,
18
+ tokenBreakdown: {
19
+ inputTokens: 646900000,
20
+ outputTokens: 34500000,
21
+ cacheReadTokens: 10300000000,
22
+ cacheWriteTokens: 11000000,
23
+ reasoningTokens: null
24
+ },
25
+ daily: [
26
+ {
27
+ date: "2026-06-06",
28
+ totalTokens: 158000000,
29
+ inputTokens: null,
30
+ outputTokens: null,
31
+ cacheReadTokens: null,
32
+ cacheWriteTokens: null,
33
+ reasoningTokens: null
34
+ }
35
+ ]
36
+ },
37
+ models: {
38
+ favoriteModel: {
39
+ model: "gpt-5-codex",
40
+ displayName: "GPT-5 Codex",
41
+ totalTokens: 7000000000,
42
+ usageCount: null,
43
+ basis: "tokens",
44
+ inputTokens: null,
45
+ outputTokens: null,
46
+ cacheReadTokens: null,
47
+ cacheWriteTokens: null,
48
+ reasoningTokens: null
49
+ },
50
+ items: [
51
+ {
52
+ model: "gpt-5-codex",
53
+ displayName: "GPT-5 Codex",
54
+ totalTokens: 7000000000,
55
+ usageCount: null,
56
+ basis: "tokens",
57
+ inputTokens: null,
58
+ outputTokens: null,
59
+ cacheReadTokens: null,
60
+ cacheWriteTokens: null,
61
+ reasoningTokens: null
62
+ }
63
+ ]
64
+ },
65
+ activity: {
66
+ longestTaskDurationMs: 6780000,
67
+ currentStreakDays: 46,
68
+ longestStreakDays: 46,
69
+ fastModePercent: 55,
70
+ reasoningEffort: "xhigh",
71
+ reasoningEffortPercent: 76,
72
+ totalThreads: 1735
73
+ },
74
+ skills: {
75
+ exploredCount: 49,
76
+ totalUsed: 3144,
77
+ topSkills: [
78
+ {
79
+ id: "pr-merge-cleanup",
80
+ name: "pr-merge-cleanup",
81
+ displayName: "pr-merge-cleanup",
82
+ usageCount: 563
83
+ }
84
+ ]
85
+ },
86
+ plugins: {
87
+ topPlugins: []
88
+ },
89
+ codexAssets: {
90
+ avatar: {
91
+ kind: "remote-url",
92
+ url: "/assets/postmelee-avatar.png",
93
+ assetRef: null,
94
+ contentType: "image/png"
95
+ },
96
+ pet: null
97
+ },
98
+ extensions: {
99
+ "codexUsageAnalyzer.fixture": {
100
+ note: "sample-backed skeleton"
101
+ }
102
+ }
103
+ });
package/src/index.d.ts ADDED
@@ -0,0 +1,46 @@
1
+ import type { UsageSnapshotV2 } from "./snapshot/v2-types.js";
2
+
3
+ export * from "./snapshot/v2-types.js";
4
+
5
+ export declare const ANALYZER_NAME: "codex-usage-analyzer";
6
+ export declare const ANALYZER_VERSION: string;
7
+
8
+ export interface AnalyzeUsageOptions {
9
+ /**
10
+ * Capture timestamp for the production snapshot. Defaults to the analyzer run time.
11
+ */
12
+ capturedAt?: string | Date | null;
13
+ /**
14
+ * Local Codex home root for parser source discovery.
15
+ */
16
+ codexHome?: string | null;
17
+ /**
18
+ * Deterministic clock value for date and streak calculations in parser tests.
19
+ */
20
+ now?: string | Date | null;
21
+ }
22
+
23
+ export interface CliIo {
24
+ stdout?: {
25
+ write(chunk: string): unknown;
26
+ };
27
+ stderr?: {
28
+ write(chunk: string): unknown;
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Analyze usage into a production UsageSnapshot v2.
34
+ *
35
+ * Local session JSONL sources, including allowlisted usage and skill/plugin
36
+ * invocation events, are parsed when available. Unavailable fields are
37
+ * represented with zero/null/empty values plus a namespaced diagnostic
38
+ * extension. This function does not return the sample fixture.
39
+ */
40
+ export declare function analyzeUsage(options?: AnalyzeUsageOptions): Promise<UsageSnapshotV2>;
41
+
42
+ export declare function createSampleUsageSnapshotV2(
43
+ overrides?: Record<string, unknown>
44
+ ): UsageSnapshotV2;
45
+
46
+ export declare function runCli(argv: string[], io?: CliIo): Promise<number>;
package/src/index.js ADDED
@@ -0,0 +1,17 @@
1
+ export {
2
+ ANALYZER_NAME,
3
+ ANALYZER_VERSION,
4
+ analyzeUsage,
5
+ createSampleUsageSnapshotV2
6
+ } from "./analyze.js";
7
+
8
+ export {
9
+ USAGE_SNAPSHOT_V2_SCHEMA_VERSION,
10
+ assertUsageSnapshotV2,
11
+ isUsageSnapshotV2,
12
+ validateUsageSnapshotV2
13
+ } from "./snapshot/v2-schema.js";
14
+
15
+ export { sampleUsageSnapshotV2 } from "./fixtures/sample-v2-snapshot.js";
16
+
17
+ export { runCli } from "./cli.js";
@@ -0,0 +1,264 @@
1
+ import { resolveCodexHome } from "./codex-home.js";
2
+ import {
3
+ discoverSessionJsonlFiles,
4
+ normalizeSessionTokenCountEvent,
5
+ readSessionJsonlEntries
6
+ } from "./session-jsonl.js";
7
+ import {
8
+ getTokenUsageTotal,
9
+ getUtcDate
10
+ } from "./token-aggregate.js";
11
+
12
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
13
+
14
+ export async function aggregateActivityFromCodexHome(options = {}) {
15
+ const { codexHome, source } = resolveCodexHome(options);
16
+ const discovery = await discoverSessionJsonlFiles(codexHome);
17
+ const aggregate = await aggregateActivityFromSessionFiles(discovery.files, options);
18
+
19
+ aggregate.diagnostics.source = source;
20
+ aggregate.diagnostics.discovery = summarizeDiscovery(discovery.diagnostics);
21
+
22
+ if (discovery.files.length === 0) {
23
+ aggregate.diagnostics.status = "unavailable";
24
+ aggregate.diagnostics.reason = "session_jsonl_not_found";
25
+ }
26
+
27
+ return aggregate;
28
+ }
29
+
30
+ export async function aggregateActivityFromSessionFiles(files, options = {}) {
31
+ const state = createAggregateState(normalizeNow(options.now));
32
+
33
+ for await (const entry of readSessionJsonlEntries(files)) {
34
+ state.diagnostics.entriesScanned += 1;
35
+
36
+ if (entry.kind === "malformed_line") {
37
+ state.diagnostics.malformedLines += 1;
38
+ continue;
39
+ }
40
+
41
+ if (entry.kind === "file_error") {
42
+ state.diagnostics.fileErrors += 1;
43
+ continue;
44
+ }
45
+
46
+ const tokenEvent = normalizeSessionTokenCountEvent(entry.event);
47
+ if (tokenEvent === null) {
48
+ state.diagnostics.ignoredEvents += 1;
49
+ continue;
50
+ }
51
+
52
+ state.diagnostics.tokenEvents += 1;
53
+ applyDuration(state, tokenEvent.durationMs);
54
+ applyEffort(state, tokenEvent.effort);
55
+ applyModeDiagnostic(state, tokenEvent.mode);
56
+ applyUsageDate(state, tokenEvent);
57
+ }
58
+
59
+ return finalizeAggregate(files.length, state);
60
+ }
61
+
62
+ function createAggregateState(now) {
63
+ return {
64
+ datesWithUsage: new Set(),
65
+ diagnostics: {
66
+ status: "unavailable",
67
+ reason: "no_activity_events",
68
+ source: "session_jsonl",
69
+ filesScanned: 0,
70
+ entriesScanned: 0,
71
+ ignoredEvents: 0,
72
+ tokenEvents: 0,
73
+ tokenEventsWithDateUsage: 0,
74
+ tokenEventsWithDuration: 0,
75
+ tokenEventsWithEffort: 0,
76
+ tokenEventsWithMode: 0,
77
+ malformedLines: 0,
78
+ fileErrors: 0,
79
+ unavailableFields: ["fastModePercent"],
80
+ fastModeReason: "source_unconfirmed",
81
+ streakDateBasis: "utc_date_from_session_token_usage",
82
+ profileParity: "not_guaranteed",
83
+ profileParityReason: "remote_profile_api_not_used",
84
+ discovery: []
85
+ },
86
+ effortCounts: new Map(),
87
+ effortOrder: new Map(),
88
+ longestTaskDurationMs: null,
89
+ now
90
+ };
91
+ }
92
+
93
+ function applyDuration(state, value) {
94
+ const durationMs = readNonNegativeInteger(value);
95
+ if (durationMs === null) {
96
+ return;
97
+ }
98
+
99
+ state.diagnostics.tokenEventsWithDuration += 1;
100
+ state.longestTaskDurationMs = Math.max(state.longestTaskDurationMs ?? 0, durationMs);
101
+ }
102
+
103
+ function applyEffort(state, value) {
104
+ const effort = readNonEmptyString(value);
105
+ if (effort === null) {
106
+ return;
107
+ }
108
+
109
+ state.diagnostics.tokenEventsWithEffort += 1;
110
+ state.effortCounts.set(effort, (state.effortCounts.get(effort) ?? 0) + 1);
111
+ if (!state.effortOrder.has(effort)) {
112
+ state.effortOrder.set(effort, state.effortOrder.size);
113
+ }
114
+ }
115
+
116
+ function applyModeDiagnostic(state, value) {
117
+ if (readNonEmptyString(value) !== null) {
118
+ state.diagnostics.tokenEventsWithMode += 1;
119
+ }
120
+ }
121
+
122
+ function applyUsageDate(state, tokenEvent) {
123
+ if (tokenEvent.lastTokenUsage === null) {
124
+ return;
125
+ }
126
+
127
+ const totalTokens = getTokenUsageTotal(tokenEvent.lastTokenUsage);
128
+ if (totalTokens === null || totalTokens <= 0) {
129
+ return;
130
+ }
131
+
132
+ const date = getUtcDate(tokenEvent.timestamp);
133
+ if (date === null) {
134
+ return;
135
+ }
136
+
137
+ state.diagnostics.tokenEventsWithDateUsage += 1;
138
+ state.datesWithUsage.add(date);
139
+ }
140
+
141
+ function finalizeAggregate(filesScanned, state) {
142
+ const effort = getDominantEffort(state);
143
+ const streak = calculateStreaks(state.datesWithUsage, state.now);
144
+ const hasActivity = filesScanned > 0 || state.diagnostics.tokenEvents > 0;
145
+
146
+ state.diagnostics.filesScanned = filesScanned;
147
+ state.diagnostics.status = hasActivity ? "ok" : state.diagnostics.status;
148
+ state.diagnostics.reason = hasActivity ? null : state.diagnostics.reason;
149
+
150
+ return {
151
+ activity: {
152
+ longestTaskDurationMs: state.longestTaskDurationMs,
153
+ currentStreakDays: streak.currentStreakDays,
154
+ longestStreakDays: streak.longestStreakDays,
155
+ fastModePercent: null,
156
+ reasoningEffort: effort?.name ?? null,
157
+ reasoningEffortPercent: effort?.percent ?? null,
158
+ totalThreads: filesScanned > 0 ? filesScanned : null
159
+ },
160
+ diagnostics: state.diagnostics
161
+ };
162
+ }
163
+
164
+ function getDominantEffort(state) {
165
+ const total = state.diagnostics.tokenEventsWithEffort;
166
+ if (total === 0) {
167
+ return null;
168
+ }
169
+
170
+ const [name, count] = Array.from(state.effortCounts.entries())
171
+ .sort(([leftName, leftCount], [rightName, rightCount]) => {
172
+ const countDiff = rightCount - leftCount;
173
+ if (countDiff !== 0) return countDiff;
174
+
175
+ const orderDiff = state.effortOrder.get(leftName) - state.effortOrder.get(rightName);
176
+ if (orderDiff !== 0) return orderDiff;
177
+
178
+ return leftName.localeCompare(rightName);
179
+ })[0];
180
+
181
+ return {
182
+ name,
183
+ percent: roundPercent((count / total) * 100)
184
+ };
185
+ }
186
+
187
+ function calculateStreaks(dates, now) {
188
+ if (dates.size === 0) {
189
+ return {
190
+ currentStreakDays: null,
191
+ longestStreakDays: null
192
+ };
193
+ }
194
+
195
+ const dayNumbers = Array.from(dates)
196
+ .map(dateToDayNumber)
197
+ .sort((left, right) => left - right);
198
+
199
+ let longest = 0;
200
+ let run = 0;
201
+ let previous = null;
202
+
203
+ for (const dayNumber of dayNumbers) {
204
+ run = previous !== null && dayNumber === previous + 1 ? run + 1 : 1;
205
+ longest = Math.max(longest, run);
206
+ previous = dayNumber;
207
+ }
208
+
209
+ const nowDay = dateToDayNumber(getUtcDate(now.toISOString()));
210
+ let current = 0;
211
+ for (let day = nowDay; dates.has(dayNumberToDate(day)); day -= 1) {
212
+ current += 1;
213
+ }
214
+
215
+ return {
216
+ currentStreakDays: current,
217
+ longestStreakDays: longest
218
+ };
219
+ }
220
+
221
+ function normalizeNow(value) {
222
+ if (value === undefined || value === null) {
223
+ return new Date();
224
+ }
225
+
226
+ const date = value instanceof Date ? value : new Date(value);
227
+ if (Number.isNaN(date.getTime())) {
228
+ throw new TypeError("now must be a valid date");
229
+ }
230
+
231
+ return date;
232
+ }
233
+
234
+ function dateToDayNumber(date) {
235
+ return Math.floor(Date.parse(`${date}T00:00:00.000Z`) / MS_PER_DAY);
236
+ }
237
+
238
+ function dayNumberToDate(dayNumber) {
239
+ return new Date(dayNumber * MS_PER_DAY).toISOString().slice(0, 10);
240
+ }
241
+
242
+ function readNonEmptyString(value) {
243
+ if (typeof value !== "string") {
244
+ return null;
245
+ }
246
+
247
+ const trimmed = value.trim();
248
+ return trimmed.length > 0 ? trimmed : null;
249
+ }
250
+
251
+ function readNonNegativeInteger(value) {
252
+ return Number.isInteger(value) && value >= 0 ? value : null;
253
+ }
254
+
255
+ function roundPercent(value) {
256
+ return Math.round(value * 100) / 100;
257
+ }
258
+
259
+ function summarizeDiscovery(diagnostics) {
260
+ return diagnostics.map((diagnostic) => ({
261
+ code: diagnostic.code,
262
+ severity: diagnostic.severity
263
+ }));
264
+ }