codex-devtools 0.1.11 → 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,5 +1,6 @@
1
1
  "use strict";
2
2
  const fs = require("node:fs");
3
+ const node_crypto = require("node:crypto");
3
4
  const path = require("node:path");
4
5
  const node_buffer = require("node:buffer");
5
6
  const os = require("node:os");
@@ -34,6 +35,12 @@ const createLogger = (scope) => {
34
35
  debug: (message, ...args) => console.debug(prefix, message, ...args)
35
36
  };
36
37
  };
38
+ const DEFAULT_CODEX_DEVTOOLS_CONFIG_PATH = path__namespace.join(
39
+ os__namespace.homedir(),
40
+ ".config",
41
+ "codex-devtools",
42
+ "config.json"
43
+ );
37
44
  const DEFAULT_CONFIG = {
38
45
  general: {
39
46
  launchAtLogin: false,
@@ -66,7 +73,7 @@ function deepMerge(target, source) {
66
73
  return output;
67
74
  }
68
75
  class ConfigManager {
69
- constructor(configPath = path__namespace.join(os__namespace.homedir(), ".config", "codex-devtools", "config.json")) {
76
+ constructor(configPath = DEFAULT_CODEX_DEVTOOLS_CONFIG_PATH) {
70
77
  this.configPath = configPath;
71
78
  this.config = this.loadConfig();
72
79
  }
@@ -117,68 +124,68 @@ class ConfigManager {
117
124
  fs__namespace.writeFileSync(this.configPath, JSON.stringify(config, null, 2), "utf8");
118
125
  }
119
126
  }
120
- function isRecord(value) {
127
+ function isRecord$2(value) {
121
128
  return typeof value === "object" && value !== null;
122
129
  }
123
- function isString(value) {
130
+ function isString$2(value) {
124
131
  return typeof value === "string";
125
132
  }
126
133
  function hasString(obj, key) {
127
- return isString(obj[key]);
134
+ return isString$2(obj[key]);
128
135
  }
129
136
  function hasOptionalString(obj, key) {
130
- return !(key in obj) || isString(obj[key]);
137
+ return !(key in obj) || isString$2(obj[key]);
131
138
  }
132
139
  function hasOptionalRecord(obj, key) {
133
- return !(key in obj) || isRecord(obj[key]);
140
+ return !(key in obj) || isRecord$2(obj[key]);
134
141
  }
135
142
  function getContentBlockText(block) {
136
143
  if (!("text" in block)) {
137
144
  return "";
138
145
  }
139
- return isString(block.text) ? block.text : "";
146
+ return isString$2(block.text) ? block.text : "";
140
147
  }
141
148
  function isReasoningSummaryText(value) {
142
- return isRecord(value) && hasString(value, "text");
149
+ return isRecord$2(value) && hasString(value, "text");
143
150
  }
144
151
  function reasoningSummaryItemToText(item) {
145
- return isString(item) ? item : item.text;
152
+ return isString$2(item) ? item : item.text;
146
153
  }
147
154
  function reasoningSummaryToText(summary) {
148
155
  return summary.map(reasoningSummaryItemToText);
149
156
  }
150
157
  function isContentBlockInputText(value) {
151
- if (!isRecord(value)) {
158
+ if (!isRecord$2(value)) {
152
159
  return false;
153
160
  }
154
161
  return value.type === "input_text" && hasString(value, "text");
155
162
  }
156
163
  function isContentBlockOutputText(value) {
157
- if (!isRecord(value)) {
164
+ if (!isRecord$2(value)) {
158
165
  return false;
159
166
  }
160
167
  return value.type === "output_text" && hasString(value, "text");
161
168
  }
162
169
  function isContentBlockInputImage(value) {
163
- if (!isRecord(value)) {
170
+ if (!isRecord$2(value)) {
164
171
  return false;
165
172
  }
166
173
  return value.type === "input_image" && hasString(value, "image_url");
167
174
  }
168
175
  function isContentBlockUnknown(value) {
169
- if (!isRecord(value) || !hasString(value, "type")) {
176
+ if (!isRecord$2(value) || !hasString(value, "type")) {
170
177
  return false;
171
178
  }
172
179
  if (value.type === "input_text" || value.type === "output_text" || value.type === "input_image") {
173
180
  return false;
174
181
  }
175
- return !("text" in value) || isString(value.text);
182
+ return !("text" in value) || isString$2(value.text);
176
183
  }
177
184
  function isContentBlock(value) {
178
185
  return isContentBlockInputText(value) || isContentBlockOutputText(value) || isContentBlockInputImage(value) || isContentBlockUnknown(value);
179
186
  }
180
187
  function isMessagePayload(value) {
181
- if (!isRecord(value)) {
188
+ if (!isRecord$2(value)) {
182
189
  return false;
183
190
  }
184
191
  if (value.type !== "message" || value.role !== "developer" && value.role !== "user" && value.role !== "assistant") {
@@ -190,70 +197,70 @@ function isMessagePayload(value) {
190
197
  return value.content.every(isContentBlock);
191
198
  }
192
199
  function isFunctionCallPayload(value) {
193
- if (!isRecord(value)) {
200
+ if (!isRecord$2(value)) {
194
201
  return false;
195
202
  }
196
203
  return value.type === "function_call" && hasString(value, "name") && hasString(value, "arguments") && hasString(value, "call_id");
197
204
  }
198
205
  function isFunctionCallOutputPayload(value) {
199
- if (!isRecord(value)) {
206
+ if (!isRecord$2(value)) {
200
207
  return false;
201
208
  }
202
209
  return value.type === "function_call_output" && hasString(value, "call_id") && hasString(value, "output");
203
210
  }
204
211
  function isReasoningPayload(value) {
205
- if (!isRecord(value)) {
212
+ if (!isRecord$2(value)) {
206
213
  return false;
207
214
  }
208
215
  if (value.type !== "reasoning" || !Array.isArray(value.summary)) {
209
216
  return false;
210
217
  }
211
- if (!value.summary.every((item) => isString(item) || isReasoningSummaryText(item))) {
218
+ if (!value.summary.every((item) => isString$2(item) || isReasoningSummaryText(item))) {
212
219
  return false;
213
220
  }
214
- return !("encrypted_content" in value) || isString(value.encrypted_content) || value.encrypted_content === null;
221
+ return !("encrypted_content" in value) || isString$2(value.encrypted_content) || value.encrypted_content === null;
215
222
  }
216
223
  function isResponseItemPayload(value) {
217
224
  return isMessagePayload(value) || isFunctionCallPayload(value) || isFunctionCallOutputPayload(value) || isReasoningPayload(value);
218
225
  }
219
226
  function isTokenUsage(value) {
220
- if (!isRecord(value)) {
227
+ if (!isRecord$2(value)) {
221
228
  return false;
222
229
  }
223
230
  return typeof value.input_tokens === "number" && typeof value.cached_input_tokens === "number" && typeof value.output_tokens === "number" && typeof value.reasoning_output_tokens === "number" && typeof value.total_tokens === "number";
224
231
  }
225
232
  function isTokenCountPayload(value) {
226
- if (!isRecord(value) || value.type !== "token_count") {
233
+ if (!isRecord$2(value) || value.type !== "token_count") {
227
234
  return false;
228
235
  }
229
236
  if (value.info === null) {
230
237
  return true;
231
238
  }
232
- if (!isRecord(value.info)) {
239
+ if (!isRecord$2(value.info)) {
233
240
  return false;
234
241
  }
235
242
  return isTokenUsage(value.info.total_token_usage) && isTokenUsage(value.info.last_token_usage) && typeof value.info.model_context_window === "number";
236
243
  }
237
244
  function isAgentReasoningPayload(value) {
238
- if (!isRecord(value)) {
245
+ if (!isRecord$2(value)) {
239
246
  return false;
240
247
  }
241
248
  return value.type === "agent_reasoning" && hasString(value, "text");
242
249
  }
243
250
  function isAgentMessagePayload(value) {
244
- if (!isRecord(value)) {
251
+ if (!isRecord$2(value)) {
245
252
  return false;
246
253
  }
247
254
  return value.type === "agent_message" && hasString(value, "message");
248
255
  }
249
256
  function isUserMessagePayload(value) {
250
- if (!isRecord(value)) {
257
+ if (!isRecord$2(value)) {
251
258
  return false;
252
259
  }
253
260
  return value.type === "user_message" && hasString(value, "message");
254
261
  }
255
262
  function isContextCompactedPayload(value) {
256
- if (!isRecord(value)) {
263
+ if (!isRecord$2(value)) {
257
264
  return false;
258
265
  }
259
266
  return value.type === "context_compacted";
@@ -262,10 +269,10 @@ function isEventMsgPayload(value) {
262
269
  return isTokenCountPayload(value) || isAgentReasoningPayload(value) || isAgentMessagePayload(value) || isUserMessagePayload(value) || isContextCompactedPayload(value);
263
270
  }
264
271
  function isSessionMetaEntry(value) {
265
- if (!isRecord(value) || value.type !== "session_meta" || !hasString(value, "timestamp")) {
272
+ if (!isRecord$2(value) || value.type !== "session_meta" || !hasString(value, "timestamp")) {
266
273
  return false;
267
274
  }
268
- if (!isRecord(value.payload)) {
275
+ if (!isRecord$2(value.payload)) {
269
276
  return false;
270
277
  }
271
278
  if (!hasOptionalString(value.payload, "id") || !hasOptionalString(value.payload, "cwd") || !hasOptionalString(value.payload, "originator") || !hasOptionalString(value.payload, "cli_version") || !hasOptionalString(value.payload, "model_provider") || !hasOptionalString(value.payload, "model")) {
@@ -273,14 +280,14 @@ function isSessionMetaEntry(value) {
273
280
  }
274
281
  if ("base_instructions" in value.payload) {
275
282
  const baseInstructions = value.payload.base_instructions;
276
- if (!isString(baseInstructions) && !isRecord(baseInstructions)) {
283
+ if (!isString$2(baseInstructions) && !isRecord$2(baseInstructions)) {
277
284
  return false;
278
285
  }
279
286
  }
280
287
  if (!hasOptionalRecord(value.payload, "git")) {
281
288
  return false;
282
289
  }
283
- if (isRecord(value.payload.git)) {
290
+ if (isRecord$2(value.payload.git)) {
284
291
  if (!hasOptionalString(value.payload.git, "commit_hash") || !hasOptionalString(value.payload.git, "branch") || !hasOptionalString(value.payload.git, "repository_url")) {
285
292
  return false;
286
293
  }
@@ -288,55 +295,55 @@ function isSessionMetaEntry(value) {
288
295
  return hasString(value.payload, "id") || hasString(value.payload, "cwd");
289
296
  }
290
297
  function isResponseItemEntry(value) {
291
- if (!isRecord(value) || value.type !== "response_item" || !hasString(value, "timestamp")) {
298
+ if (!isRecord$2(value) || value.type !== "response_item" || !hasString(value, "timestamp")) {
292
299
  return false;
293
300
  }
294
301
  return isResponseItemPayload(value.payload);
295
302
  }
296
303
  function isTurnContextEntry(value) {
297
- if (!isRecord(value) || value.type !== "turn_context" || !hasString(value, "timestamp")) {
304
+ if (!isRecord$2(value) || value.type !== "turn_context" || !hasString(value, "timestamp")) {
298
305
  return false;
299
306
  }
300
- if (!isRecord(value.payload)) {
307
+ if (!isRecord$2(value.payload)) {
301
308
  return false;
302
309
  }
303
310
  if (!hasOptionalString(value.payload, "turn_id") || !hasOptionalString(value.payload, "cwd") || !hasOptionalString(value.payload, "approval_policy") || !hasOptionalString(value.payload, "model") || !hasOptionalString(value.payload, "personality") || !hasOptionalString(value.payload, "effort") || !hasOptionalString(value.payload, "summary") || !hasOptionalString(value.payload, "user_instructions")) {
304
311
  return false;
305
312
  }
306
313
  const sandboxPolicy = value.payload.sandbox_policy;
307
- if ("sandbox_policy" in value.payload && !(isString(sandboxPolicy) || isRecord(sandboxPolicy))) {
314
+ if ("sandbox_policy" in value.payload && !(isString$2(sandboxPolicy) || isRecord$2(sandboxPolicy))) {
308
315
  return false;
309
316
  }
310
317
  const collaborationMode = value.payload.collaboration_mode;
311
- if ("collaboration_mode" in value.payload && !(isString(collaborationMode) || isRecord(collaborationMode))) {
318
+ if ("collaboration_mode" in value.payload && !(isString$2(collaborationMode) || isRecord$2(collaborationMode))) {
312
319
  return false;
313
320
  }
314
321
  const truncationPolicy = value.payload.truncation_policy;
315
- if ("truncation_policy" in value.payload && !(isString(truncationPolicy) || isRecord(truncationPolicy))) {
322
+ if ("truncation_policy" in value.payload && !(isString$2(truncationPolicy) || isRecord$2(truncationPolicy))) {
316
323
  return false;
317
324
  }
318
325
  return hasString(value.payload, "cwd") || hasString(value.payload, "model");
319
326
  }
320
327
  function isEventMsgEntry(value) {
321
- if (!isRecord(value) || value.type !== "event_msg" || !hasString(value, "timestamp")) {
328
+ if (!isRecord$2(value) || value.type !== "event_msg" || !hasString(value, "timestamp")) {
322
329
  return false;
323
330
  }
324
331
  return isEventMsgPayload(value.payload);
325
332
  }
326
333
  function isCompactedEntry(value) {
327
- if (!isRecord(value) || value.type !== "compacted" || !hasString(value, "timestamp")) {
334
+ if (!isRecord$2(value) || value.type !== "compacted" || !hasString(value, "timestamp")) {
328
335
  return false;
329
336
  }
330
- return isRecord(value.payload);
337
+ return isRecord$2(value.payload);
331
338
  }
332
339
  function isCompactionEntry(value) {
333
- if (!isRecord(value) || value.type !== "compaction" || !hasString(value, "timestamp")) {
340
+ if (!isRecord$2(value) || value.type !== "compaction" || !hasString(value, "timestamp")) {
334
341
  return false;
335
342
  }
336
- if ("payload" in value && !isRecord(value.payload)) {
343
+ if ("payload" in value && !isRecord$2(value.payload)) {
337
344
  return false;
338
345
  }
339
- if ("encrypted_content" in value && !(isString(value.encrypted_content) || value.encrypted_content === null)) {
346
+ if ("encrypted_content" in value && !(isString$2(value.encrypted_content) || value.encrypted_content === null)) {
340
347
  return false;
341
348
  }
342
349
  return true;
@@ -354,6 +361,24 @@ const EMPTY_CODEX_SESSION_METRICS = {
354
361
  toolCallCount: 0,
355
362
  duration: 0
356
363
  };
364
+ function diffTokenUsage(previous, current) {
365
+ const delta = {
366
+ input_tokens: current.input_tokens - previous.input_tokens,
367
+ cached_input_tokens: current.cached_input_tokens - previous.cached_input_tokens,
368
+ output_tokens: current.output_tokens - previous.output_tokens,
369
+ reasoning_output_tokens: current.reasoning_output_tokens - previous.reasoning_output_tokens,
370
+ total_tokens: current.total_tokens - previous.total_tokens
371
+ };
372
+ const hasNegative = delta.input_tokens < 0 || delta.cached_input_tokens < 0 || delta.output_tokens < 0 || delta.reasoning_output_tokens < 0 || delta.total_tokens < 0;
373
+ if (hasNegative) {
374
+ return null;
375
+ }
376
+ const isDuplicate = delta.input_tokens === 0 && delta.cached_input_tokens === 0 && delta.output_tokens === 0 && delta.reasoning_output_tokens === 0 && delta.total_tokens === 0;
377
+ return isDuplicate ? null : delta;
378
+ }
379
+ function isSameTokenUsage(left, right) {
380
+ return left.input_tokens === right.input_tokens && left.cached_input_tokens === right.cached_input_tokens && left.output_tokens === right.output_tokens && left.reasoning_output_tokens === right.reasoning_output_tokens && left.total_tokens === right.total_tokens;
381
+ }
357
382
  const AGENTS_HEADING_PATTERN = /^#?\s*AGENTS\.md instructions\b/i;
358
383
  const AGENTS_INSTRUCTIONS_BLOCK_PATTERN = /<INSTRUCTIONS>[\s\S]*<\/INSTRUCTIONS>/i;
359
384
  const ENVIRONMENT_CONTEXT_WRAPPER_PATTERN = /^<environment_context>[\s\S]*<\/environment_context>$/i;
@@ -737,10 +762,10 @@ function pickPreferredEquivalentUserContent(pending, incoming) {
737
762
  }
738
763
  return pending;
739
764
  }
740
- function normalizeModel$2(model) {
765
+ function normalizeModel$3(model) {
741
766
  return model?.trim() ?? "";
742
767
  }
743
- function normalizeReasoningEffort$2(effort) {
768
+ function normalizeReasoningEffort$3(effort) {
744
769
  const value = effort?.trim();
745
770
  return value ? value : "unknown";
746
771
  }
@@ -1077,11 +1102,11 @@ class CodexChunkBuilder {
1077
1102
  continue;
1078
1103
  }
1079
1104
  if (isTurnContextEntry(entry)) {
1080
- const model = normalizeModel$2(entry.payload.model);
1105
+ const model = normalizeModel$3(entry.payload.model);
1081
1106
  if (model) {
1082
1107
  const usage = {
1083
1108
  model,
1084
- reasoningEffort: normalizeReasoningEffort$2(entry.payload.effort)
1109
+ reasoningEffort: normalizeReasoningEffort$3(entry.payload.effort)
1085
1110
  };
1086
1111
  if (lastSeenModelUsage === null) {
1087
1112
  lastSeenModelUsage = usage;
@@ -1296,7 +1321,395 @@ class CodexChunkBuilder {
1296
1321
  ai.pendingUsageToolIndex = null;
1297
1322
  }
1298
1323
  }
1299
- const logger$2 = createLogger("Main:jsonl");
1324
+ function normalizeRateKey(model) {
1325
+ return model.trim().toLowerCase();
1326
+ }
1327
+ function createZeroTokenTotals() {
1328
+ return {
1329
+ totalTokens: 0,
1330
+ inputTokens: 0,
1331
+ outputTokens: 0,
1332
+ cachedTokens: 0,
1333
+ reasoningTokens: 0
1334
+ };
1335
+ }
1336
+ function addTokenTotals(target, source) {
1337
+ target.totalTokens += source.totalTokens;
1338
+ target.inputTokens += source.inputTokens;
1339
+ target.outputTokens += source.outputTokens;
1340
+ target.cachedTokens += source.cachedTokens;
1341
+ target.reasoningTokens += source.reasoningTokens;
1342
+ }
1343
+ function tokenUsageToTotals(usage) {
1344
+ return {
1345
+ totalTokens: usage.total_tokens,
1346
+ inputTokens: usage.input_tokens,
1347
+ outputTokens: usage.output_tokens,
1348
+ cachedTokens: usage.cached_input_tokens,
1349
+ reasoningTokens: usage.reasoning_output_tokens
1350
+ };
1351
+ }
1352
+ function parseTimestamp(timestamp) {
1353
+ const value = new Date(timestamp);
1354
+ return Number.isNaN(value.getTime()) ? null : value;
1355
+ }
1356
+ function localDateKey(date) {
1357
+ const year = date.getFullYear();
1358
+ const month = String(date.getMonth() + 1).padStart(2, "0");
1359
+ const day = String(date.getDate()).padStart(2, "0");
1360
+ return `${year}-${month}-${day}`;
1361
+ }
1362
+ function localHour(date) {
1363
+ return date.getHours();
1364
+ }
1365
+ function ensureDailyBucket(buckets, date) {
1366
+ const existing = buckets.get(date);
1367
+ if (existing) {
1368
+ return existing;
1369
+ }
1370
+ const next = {
1371
+ date,
1372
+ eventCount: 0,
1373
+ ...createZeroTokenTotals()
1374
+ };
1375
+ buckets.set(date, next);
1376
+ return next;
1377
+ }
1378
+ function ensureHourlyBucket(buckets, hour) {
1379
+ const existing = buckets.get(hour);
1380
+ if (existing) {
1381
+ return existing;
1382
+ }
1383
+ const next = {
1384
+ hour,
1385
+ eventCount: 0,
1386
+ ...createZeroTokenTotals()
1387
+ };
1388
+ buckets.set(hour, next);
1389
+ return next;
1390
+ }
1391
+ function ensureModelBucket(buckets, model, reasoningEffort) {
1392
+ const key = `${normalizeRateKey(model)}::${normalizeReasoningEffort$2(reasoningEffort)}`;
1393
+ const existing = buckets.get(key);
1394
+ if (existing) {
1395
+ return existing;
1396
+ }
1397
+ const next = {
1398
+ model,
1399
+ reasoningEffort,
1400
+ ...createZeroTokenTotals()
1401
+ };
1402
+ buckets.set(key, next);
1403
+ return next;
1404
+ }
1405
+ function estimateUsageCostUsd(tokens, rate) {
1406
+ const nonReasoningOutput = Math.max(tokens.outputTokens - tokens.reasoningTokens, 0);
1407
+ const outputCost = nonReasoningOutput / 1e6 * rate.outputUsdPer1M;
1408
+ const reasoningCost = tokens.reasoningTokens / 1e6 * rate.reasoningOutputUsdPer1M;
1409
+ return tokens.inputTokens / 1e6 * rate.inputUsdPer1M + tokens.cachedTokens / 1e6 * rate.cachedInputUsdPer1M + outputCost + reasoningCost;
1410
+ }
1411
+ function buildRateLookup(rateCard) {
1412
+ const lookup = /* @__PURE__ */ new Map();
1413
+ for (const rate of rateCard.models) {
1414
+ lookup.set(normalizeRateKey(rate.model), rate);
1415
+ }
1416
+ return lookup;
1417
+ }
1418
+ function buildHourlyResult(hourly) {
1419
+ const result = [];
1420
+ for (let hour = 0; hour <= 23; hour += 1) {
1421
+ const current = hourly.get(hour);
1422
+ if (!current) {
1423
+ result.push({
1424
+ hour,
1425
+ eventCount: 0,
1426
+ sessionCount: 0,
1427
+ ...createZeroTokenTotals()
1428
+ });
1429
+ continue;
1430
+ }
1431
+ result.push({
1432
+ hour,
1433
+ eventCount: current.eventCount,
1434
+ sessionCount: current.sessions.size,
1435
+ totalTokens: current.totalTokens,
1436
+ inputTokens: current.inputTokens,
1437
+ outputTokens: current.outputTokens,
1438
+ cachedTokens: current.cachedTokens,
1439
+ reasoningTokens: current.reasoningTokens
1440
+ });
1441
+ }
1442
+ return result;
1443
+ }
1444
+ function normalizeModel$2(model) {
1445
+ const trimmed = model?.trim();
1446
+ return trimmed && trimmed.length > 0 ? trimmed : "unknown-model";
1447
+ }
1448
+ function normalizeReasoningEffort$2(reasoningEffort) {
1449
+ const trimmed = reasoningEffort?.trim();
1450
+ return trimmed && trimmed.length > 0 ? trimmed : "unknown";
1451
+ }
1452
+ function buildSessionStatsRecord(parsed, revision, nowIso) {
1453
+ const dailyBuckets = /* @__PURE__ */ new Map();
1454
+ const hourlyBuckets = /* @__PURE__ */ new Map();
1455
+ const modelBuckets = /* @__PURE__ */ new Map();
1456
+ const tokens = createZeroTokenTotals();
1457
+ let eventCount = 0;
1458
+ let currentModel = normalizeModel$2(parsed.session.model || parsed.sessionMeta?.payload.model);
1459
+ let currentReasoningEffort = "unknown";
1460
+ let previousTotalUsage = null;
1461
+ for (const modelUsage of parsed.session.modelUsages) {
1462
+ if (modelUsage.model) {
1463
+ currentModel = normalizeModel$2(modelUsage.model);
1464
+ currentReasoningEffort = normalizeReasoningEffort$2(modelUsage.reasoningEffort);
1465
+ break;
1466
+ }
1467
+ }
1468
+ for (const entry of parsed.entries) {
1469
+ if (isTurnContextEntry(entry)) {
1470
+ if (entry.payload.model) {
1471
+ currentModel = normalizeModel$2(entry.payload.model);
1472
+ }
1473
+ currentReasoningEffort = normalizeReasoningEffort$2(entry.payload.effort);
1474
+ continue;
1475
+ }
1476
+ if (!isEventMsgEntry(entry) || !isTokenCountPayload(entry.payload) || !entry.payload.info) {
1477
+ continue;
1478
+ }
1479
+ const timestamp = parseTimestamp(entry.timestamp);
1480
+ const currentTotal = entry.payload.info.total_token_usage;
1481
+ let usage;
1482
+ if (previousTotalUsage) {
1483
+ const delta = diffTokenUsage(previousTotalUsage, currentTotal);
1484
+ if (delta) {
1485
+ usage = delta;
1486
+ } else if (isSameTokenUsage(previousTotalUsage, currentTotal)) {
1487
+ previousTotalUsage = currentTotal;
1488
+ continue;
1489
+ } else {
1490
+ usage = entry.payload.info.last_token_usage;
1491
+ }
1492
+ } else {
1493
+ usage = currentTotal;
1494
+ }
1495
+ previousTotalUsage = currentTotal;
1496
+ const usageTotals = tokenUsageToTotals(usage);
1497
+ addTokenTotals(tokens, usageTotals);
1498
+ const modelTotals = ensureModelBucket(modelBuckets, currentModel, currentReasoningEffort);
1499
+ addTokenTotals(modelTotals, usageTotals);
1500
+ if (!timestamp) {
1501
+ continue;
1502
+ }
1503
+ const dateBucket = ensureDailyBucket(dailyBuckets, localDateKey(timestamp));
1504
+ addTokenTotals(dateBucket, usageTotals);
1505
+ const hourBucket = ensureHourlyBucket(hourlyBuckets, localHour(timestamp));
1506
+ addTokenTotals(hourBucket, usageTotals);
1507
+ }
1508
+ for (const classifiedEntry of parsed.classifiedEntries) {
1509
+ const timestamp = parseTimestamp(classifiedEntry.entry.timestamp);
1510
+ if (!timestamp) {
1511
+ continue;
1512
+ }
1513
+ eventCount += 1;
1514
+ const dateBucket = ensureDailyBucket(dailyBuckets, localDateKey(timestamp));
1515
+ dateBucket.eventCount += 1;
1516
+ const hourBucket = ensureHourlyBucket(hourlyBuckets, localHour(timestamp));
1517
+ hourBucket.eventCount += 1;
1518
+ }
1519
+ const lastActivity = parsed.entries[parsed.entries.length - 1]?.timestamp ?? parsed.session.startTime;
1520
+ return {
1521
+ tokenComputationVersion: 2,
1522
+ sessionId: parsed.session.id,
1523
+ filePath: parsed.session.filePath,
1524
+ revision,
1525
+ archived: false,
1526
+ cwd: parsed.session.cwd,
1527
+ startTime: parsed.session.startTime,
1528
+ lastActivity,
1529
+ modelUsages: parsed.session.modelUsages,
1530
+ eventCount,
1531
+ turnCount: parsed.metrics.turnCount,
1532
+ toolCallCount: parsed.metrics.toolCallCount,
1533
+ durationMs: parsed.metrics.duration,
1534
+ tokens,
1535
+ modelTokenTotals: Array.from(modelBuckets.values()).sort((a, b) => b.totalTokens - a.totalTokens),
1536
+ dailyBuckets: Array.from(dailyBuckets.values()).sort((a, b) => a.date.localeCompare(b.date)),
1537
+ hourlyBuckets: Array.from(hourlyBuckets.values()).sort((a, b) => a.hour - b.hour),
1538
+ lastSeenAt: nowIso
1539
+ };
1540
+ }
1541
+ function aggregateStatsSummary(records, scope, rateCard) {
1542
+ const scopedRecords = records.filter((record) => {
1543
+ if (scope.type === "all") {
1544
+ return true;
1545
+ }
1546
+ return record.cwd === scope.cwd;
1547
+ });
1548
+ const daily = /* @__PURE__ */ new Map();
1549
+ const hourly = /* @__PURE__ */ new Map();
1550
+ const modelBreakdown = /* @__PURE__ */ new Map();
1551
+ const rateLookup = buildRateLookup(rateCard);
1552
+ const unpricedModels = /* @__PURE__ */ new Set();
1553
+ const totals = {
1554
+ sessions: 0,
1555
+ archivedSessions: 0,
1556
+ eventCount: 0,
1557
+ durationMs: 0,
1558
+ estimatedCostUsd: 0,
1559
+ ...createZeroTokenTotals()
1560
+ };
1561
+ const costCoverage = {
1562
+ pricedTokens: 0,
1563
+ unpricedTokens: 0
1564
+ };
1565
+ for (const record of scopedRecords) {
1566
+ totals.sessions += 1;
1567
+ if (record.archived) {
1568
+ totals.archivedSessions += 1;
1569
+ }
1570
+ totals.eventCount += record.eventCount;
1571
+ totals.durationMs += record.durationMs;
1572
+ addTokenTotals(totals, record.tokens);
1573
+ for (const bucket of record.dailyBuckets) {
1574
+ const existing = daily.get(bucket.date) ?? {
1575
+ date: bucket.date,
1576
+ eventCount: 0,
1577
+ sessions: /* @__PURE__ */ new Set(),
1578
+ ...createZeroTokenTotals()
1579
+ };
1580
+ existing.eventCount += bucket.eventCount;
1581
+ addTokenTotals(existing, bucket);
1582
+ existing.sessions.add(record.sessionId);
1583
+ daily.set(bucket.date, existing);
1584
+ }
1585
+ for (const bucket of record.hourlyBuckets) {
1586
+ const existing = hourly.get(bucket.hour) ?? {
1587
+ hour: bucket.hour,
1588
+ eventCount: 0,
1589
+ sessions: /* @__PURE__ */ new Set(),
1590
+ ...createZeroTokenTotals()
1591
+ };
1592
+ existing.eventCount += bucket.eventCount;
1593
+ addTokenTotals(existing, bucket);
1594
+ existing.sessions.add(record.sessionId);
1595
+ hourly.set(bucket.hour, existing);
1596
+ }
1597
+ for (const modelTotals of record.modelTokenTotals) {
1598
+ const rate = rateLookup.get(normalizeRateKey(modelTotals.model));
1599
+ const modelKey = `${normalizeRateKey(modelTotals.model)}::${normalizeReasoningEffort$2(modelTotals.reasoningEffort)}`;
1600
+ const existing = modelBreakdown.get(modelKey) ?? {
1601
+ model: modelTotals.model,
1602
+ reasoningEffort: modelTotals.reasoningEffort,
1603
+ estimatedCostUsd: 0,
1604
+ sessions: /* @__PURE__ */ new Set(),
1605
+ archivedSessions: /* @__PURE__ */ new Set(),
1606
+ ...createZeroTokenTotals()
1607
+ };
1608
+ addTokenTotals(existing, modelTotals);
1609
+ existing.sessions.add(record.sessionId);
1610
+ if (record.archived) {
1611
+ existing.archivedSessions.add(record.sessionId);
1612
+ }
1613
+ if (rate) {
1614
+ const usageCost = estimateUsageCostUsd(modelTotals, rate);
1615
+ existing.estimatedCostUsd += usageCost;
1616
+ totals.estimatedCostUsd += usageCost;
1617
+ costCoverage.pricedTokens += modelTotals.totalTokens;
1618
+ } else {
1619
+ costCoverage.unpricedTokens += modelTotals.totalTokens;
1620
+ unpricedModels.add(modelTotals.model);
1621
+ }
1622
+ modelBreakdown.set(modelKey, existing);
1623
+ }
1624
+ }
1625
+ const dailyPoints = Array.from(daily.values()).map((bucket) => ({
1626
+ date: bucket.date,
1627
+ eventCount: bucket.eventCount,
1628
+ sessionCount: bucket.sessions.size,
1629
+ totalTokens: bucket.totalTokens,
1630
+ inputTokens: bucket.inputTokens,
1631
+ outputTokens: bucket.outputTokens,
1632
+ cachedTokens: bucket.cachedTokens,
1633
+ reasoningTokens: bucket.reasoningTokens
1634
+ })).sort((a, b) => a.date.localeCompare(b.date));
1635
+ const hourlyPoints = buildHourlyResult(hourly);
1636
+ const models = Array.from(modelBreakdown.values()).map((model) => ({
1637
+ model: model.model,
1638
+ reasoningEffort: model.reasoningEffort,
1639
+ sessionCount: model.sessions.size,
1640
+ archivedSessionCount: model.archivedSessions.size,
1641
+ estimatedCostUsd: Number(model.estimatedCostUsd.toFixed(6)),
1642
+ totalTokens: model.totalTokens,
1643
+ inputTokens: model.inputTokens,
1644
+ outputTokens: model.outputTokens,
1645
+ cachedTokens: model.cachedTokens,
1646
+ reasoningTokens: model.reasoningTokens
1647
+ })).sort((a, b) => b.totalTokens - a.totalTokens);
1648
+ const reasoningMap = /* @__PURE__ */ new Map();
1649
+ for (const model of modelBreakdown.values()) {
1650
+ const existing = reasoningMap.get(model.reasoningEffort) ?? {
1651
+ reasoningEffort: model.reasoningEffort,
1652
+ estimatedCostUsd: 0,
1653
+ sessions: /* @__PURE__ */ new Set(),
1654
+ ...createZeroTokenTotals()
1655
+ };
1656
+ for (const sessionId of model.sessions) {
1657
+ existing.sessions.add(sessionId);
1658
+ }
1659
+ existing.estimatedCostUsd += model.estimatedCostUsd;
1660
+ addTokenTotals(existing, model);
1661
+ reasoningMap.set(model.reasoningEffort, existing);
1662
+ }
1663
+ const reasoningEfforts = Array.from(reasoningMap.values()).map((reasoning) => ({
1664
+ reasoningEffort: reasoning.reasoningEffort,
1665
+ sessionCount: reasoning.sessions.size,
1666
+ estimatedCostUsd: Number(reasoning.estimatedCostUsd.toFixed(6)),
1667
+ totalTokens: reasoning.totalTokens,
1668
+ inputTokens: reasoning.inputTokens,
1669
+ outputTokens: reasoning.outputTokens,
1670
+ cachedTokens: reasoning.cachedTokens,
1671
+ reasoningTokens: reasoning.reasoningTokens
1672
+ })).sort((a, b) => b.totalTokens - a.totalTokens);
1673
+ const topDays = [...dailyPoints].sort((a, b) => b.eventCount - a.eventCount || b.totalTokens - a.totalTokens).slice(0, 5).map((day) => ({
1674
+ date: day.date,
1675
+ eventCount: day.eventCount,
1676
+ sessionCount: day.sessionCount,
1677
+ totalTokens: day.totalTokens,
1678
+ outputTokens: day.outputTokens
1679
+ }));
1680
+ const topHours = [...hourlyPoints].sort((a, b) => b.eventCount - a.eventCount || b.totalTokens - a.totalTokens).slice(0, 5).map((hour) => ({
1681
+ hour: hour.hour,
1682
+ eventCount: hour.eventCount,
1683
+ sessionCount: hour.sessionCount,
1684
+ totalTokens: hour.totalTokens,
1685
+ outputTokens: hour.outputTokens
1686
+ }));
1687
+ return {
1688
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1689
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "local",
1690
+ scope,
1691
+ totals: {
1692
+ ...totals,
1693
+ estimatedCostUsd: Number(totals.estimatedCostUsd.toFixed(6))
1694
+ },
1695
+ daily: dailyPoints,
1696
+ hourly: hourlyPoints,
1697
+ topDays,
1698
+ topHours,
1699
+ models,
1700
+ reasoningEfforts,
1701
+ costCoverage: {
1702
+ pricedTokens: costCoverage.pricedTokens,
1703
+ unpricedTokens: costCoverage.unpricedTokens,
1704
+ unpricedModels: Array.from(unpricedModels.values()).sort((a, b) => a.localeCompare(b))
1705
+ },
1706
+ rates: {
1707
+ updatedAt: rateCard.updatedAt,
1708
+ source: rateCard.source
1709
+ }
1710
+ };
1711
+ }
1712
+ const logger$4 = createLogger("Main:jsonl");
1300
1713
  const defaultParser = (value) => value;
1301
1714
  function waitForStreamClose(stream) {
1302
1715
  if (stream.closed) {
@@ -1343,7 +1756,7 @@ async function streamJsonlFile(filePath, options) {
1343
1756
  } catch (error) {
1344
1757
  options.onError?.(error, line, lineNumber);
1345
1758
  if (!options.onError) {
1346
- logger$2.warn(`Failed to parse JSONL line ${lineNumber} in ${filePath}`, error);
1759
+ logger$4.warn(`Failed to parse JSONL line ${lineNumber} in ${filePath}`, error);
1347
1760
  }
1348
1761
  }
1349
1762
  }
@@ -1384,7 +1797,7 @@ async function readFirstJsonlEntry(filePath, parser) {
1384
1797
  break;
1385
1798
  }
1386
1799
  } catch (error) {
1387
- logger$2.warn(`Failed to parse JSONL line ${lineNumber} in ${filePath}`, error);
1800
+ logger$4.warn(`Failed to parse JSONL line ${lineNumber} in ${filePath}`, error);
1388
1801
  }
1389
1802
  }
1390
1803
  } finally {
@@ -1392,7 +1805,7 @@ async function readFirstJsonlEntry(filePath, parser) {
1392
1805
  }
1393
1806
  return firstEntry;
1394
1807
  }
1395
- const logger$1 = createLogger("Service:CodexSessionScanner");
1808
+ const logger$3 = createLogger("Service:CodexSessionScanner");
1396
1809
  function isWithinDateRange(timestamp, options) {
1397
1810
  const current = new Date(timestamp).getTime();
1398
1811
  if (Number.isNaN(current)) {
@@ -1438,7 +1851,7 @@ class CodexSessionScanner {
1438
1851
  (value) => isSessionMetaEntry(value) ? value : null
1439
1852
  );
1440
1853
  if (!metaEntry) {
1441
- logger$1.warn(`Skipping session file without valid session_meta: ${filePath}`);
1854
+ logger$3.warn(`Skipping session file without valid session_meta: ${filePath}`);
1442
1855
  continue;
1443
1856
  }
1444
1857
  if (!isWithinDateRange(metaEntry.timestamp, options)) {
@@ -1575,6 +1988,7 @@ class CodexSessionParser {
1575
1988
  let firstTimestamp = null;
1576
1989
  let lastTimestamp = null;
1577
1990
  let firstTurnContextModel = null;
1991
+ let previousTotalUsage = null;
1578
1992
  const modelUsages = [];
1579
1993
  const modelUsageKeys = /* @__PURE__ */ new Set();
1580
1994
  for (const entry of entries) {
@@ -1615,7 +2029,22 @@ class CodexSessionParser {
1615
2029
  if (isEventMsgEntry(entry)) {
1616
2030
  eventMessages.push(entry);
1617
2031
  if (isTokenCountPayload(entry.payload) && entry.payload.info) {
1618
- const usage = entry.payload.info.last_token_usage;
2032
+ const currentTotal = entry.payload.info.total_token_usage;
2033
+ let usage;
2034
+ if (previousTotalUsage) {
2035
+ const delta = diffTokenUsage(previousTotalUsage, currentTotal);
2036
+ if (delta) {
2037
+ usage = delta;
2038
+ } else if (isSameTokenUsage(previousTotalUsage, currentTotal)) {
2039
+ previousTotalUsage = currentTotal;
2040
+ continue;
2041
+ } else {
2042
+ usage = entry.payload.info.last_token_usage;
2043
+ }
2044
+ } else {
2045
+ usage = currentTotal;
2046
+ }
2047
+ previousTotalUsage = currentTotal;
1619
2048
  metrics.inputTokens += usage.input_tokens;
1620
2049
  metrics.cachedTokens += usage.cached_input_tokens;
1621
2050
  metrics.outputTokens += usage.output_tokens;
@@ -1713,7 +2142,7 @@ class DataCache {
1713
2142
  return `${cwdHash}/${sessionId}`;
1714
2143
  }
1715
2144
  }
1716
- const logger = createLogger("Service:FileWatcher");
2145
+ const logger$2 = createLogger("Service:FileWatcher");
1717
2146
  const DEBOUNCE_MS = 100;
1718
2147
  class FileWatcher extends node_events.EventEmitter {
1719
2148
  constructor(sessionsPath = path__namespace.join(os__namespace.homedir(), ".codex", "sessions")) {
@@ -1739,7 +2168,7 @@ class FileWatcher extends node_events.EventEmitter {
1739
2168
  }
1740
2169
  );
1741
2170
  } catch (error) {
1742
- logger.error(`Failed to start watcher for ${this.sessionsPath}`, error);
2171
+ logger$2.error(`Failed to start watcher for ${this.sessionsPath}`, error);
1743
2172
  }
1744
2173
  }
1745
2174
  stop() {
@@ -1787,10 +2216,179 @@ class FileWatcher extends node_events.EventEmitter {
1787
2216
  return fs__namespace.existsSync(targetPath) ? "created" : "deleted";
1788
2217
  }
1789
2218
  }
2219
+ const logger$1 = createLogger("Service:ModelRatesStore");
2220
+ const RATES_VERSION = 1;
2221
+ const BUNDLED_DEFAULT_RATES = [
2222
+ { model: "gpt-5.2", inputUsdPer1M: 1.75, cachedInputUsdPer1M: 0.175, outputUsdPer1M: 14, reasoningOutputUsdPer1M: 14 },
2223
+ { model: "gpt-5.1", inputUsdPer1M: 1.25, cachedInputUsdPer1M: 0.125, outputUsdPer1M: 10, reasoningOutputUsdPer1M: 10 },
2224
+ { model: "gpt-5", inputUsdPer1M: 1.25, cachedInputUsdPer1M: 0.125, outputUsdPer1M: 10, reasoningOutputUsdPer1M: 10 },
2225
+ { model: "gpt-5-mini", inputUsdPer1M: 0.25, cachedInputUsdPer1M: 0.025, outputUsdPer1M: 2, reasoningOutputUsdPer1M: 2 },
2226
+ { model: "gpt-5-nano", inputUsdPer1M: 0.05, cachedInputUsdPer1M: 5e-3, outputUsdPer1M: 0.4, reasoningOutputUsdPer1M: 0.4 },
2227
+ { model: "gpt-5.2-codex", inputUsdPer1M: 1.75, cachedInputUsdPer1M: 0.175, outputUsdPer1M: 14, reasoningOutputUsdPer1M: 14 },
2228
+ { model: "gpt-5.1-codex-max", inputUsdPer1M: 1.25, cachedInputUsdPer1M: 0.125, outputUsdPer1M: 10, reasoningOutputUsdPer1M: 10 },
2229
+ { model: "gpt-5.1-codex", inputUsdPer1M: 1.25, cachedInputUsdPer1M: 0.125, outputUsdPer1M: 10, reasoningOutputUsdPer1M: 10 },
2230
+ { model: "gpt-5-codex", inputUsdPer1M: 1.25, cachedInputUsdPer1M: 0.125, outputUsdPer1M: 10, reasoningOutputUsdPer1M: 10 },
2231
+ { model: "gpt-5.1-codex-mini", inputUsdPer1M: 0.25, cachedInputUsdPer1M: 0.025, outputUsdPer1M: 2, reasoningOutputUsdPer1M: 2 },
2232
+ { model: "codex-mini-latest", inputUsdPer1M: 1.5, cachedInputUsdPer1M: 0.375, outputUsdPer1M: 6, reasoningOutputUsdPer1M: 6 }
2233
+ ];
2234
+ const DEFAULT_RATE_CARD = {
2235
+ updatedAt: null,
2236
+ source: "bundled-defaults",
2237
+ models: BUNDLED_DEFAULT_RATES,
2238
+ warnings: ["Bundled rates may be outdated. Pricing refresh is currently disabled."]
2239
+ };
2240
+ function isRecord$1(value) {
2241
+ return typeof value === "object" && value !== null;
2242
+ }
2243
+ function isString$1(value) {
2244
+ return typeof value === "string";
2245
+ }
2246
+ function isFiniteNumber$1(value) {
2247
+ return typeof value === "number" && Number.isFinite(value);
2248
+ }
2249
+ function isCodexModelRate(value) {
2250
+ return isRecord$1(value) && isString$1(value.model) && isFiniteNumber$1(value.inputUsdPer1M) && isFiniteNumber$1(value.cachedInputUsdPer1M) && isFiniteNumber$1(value.outputUsdPer1M) && isFiniteNumber$1(value.reasoningOutputUsdPer1M);
2251
+ }
2252
+ function isRatesFile(value) {
2253
+ return isRecord$1(value) && isFiniteNumber$1(value.version) && (value.updatedAt === null || isString$1(value.updatedAt)) && (value.source === null || isString$1(value.source)) && Array.isArray(value.models) && value.models.every(isCodexModelRate) && Array.isArray(value.warnings) && value.warnings.every(isString$1);
2254
+ }
2255
+ function cloneRateCard(card) {
2256
+ return structuredClone(card);
2257
+ }
2258
+ class ModelRatesStore {
2259
+ constructor(filePath) {
2260
+ this.filePath = filePath;
2261
+ }
2262
+ async getRateCard() {
2263
+ const file = await this.readRateFile();
2264
+ return {
2265
+ updatedAt: file.updatedAt,
2266
+ source: file.source,
2267
+ models: file.models,
2268
+ warnings: file.warnings
2269
+ };
2270
+ }
2271
+ async refreshFromPricingPage() {
2272
+ const current = await this.getRateCard();
2273
+ return {
2274
+ ...current,
2275
+ warnings: [...current.warnings, "Pricing refresh is disabled in this build."],
2276
+ refreshed: false
2277
+ };
2278
+ }
2279
+ async readRateFile() {
2280
+ try {
2281
+ const raw = await fs.promises.readFile(this.filePath, "utf8");
2282
+ const parsed = JSON.parse(raw);
2283
+ if (!isRatesFile(parsed) || parsed.version !== RATES_VERSION) {
2284
+ return {
2285
+ version: RATES_VERSION,
2286
+ updatedAt: DEFAULT_RATE_CARD.updatedAt,
2287
+ source: DEFAULT_RATE_CARD.source,
2288
+ models: cloneRateCard(DEFAULT_RATE_CARD).models,
2289
+ warnings: [...DEFAULT_RATE_CARD.warnings]
2290
+ };
2291
+ }
2292
+ return parsed;
2293
+ } catch (error) {
2294
+ if (error.code !== "ENOENT") {
2295
+ logger$1.warn("Failed to load model rates, falling back to bundled defaults", error);
2296
+ }
2297
+ return {
2298
+ version: RATES_VERSION,
2299
+ updatedAt: DEFAULT_RATE_CARD.updatedAt,
2300
+ source: DEFAULT_RATE_CARD.source,
2301
+ models: cloneRateCard(DEFAULT_RATE_CARD).models,
2302
+ warnings: [...DEFAULT_RATE_CARD.warnings]
2303
+ };
2304
+ }
2305
+ }
2306
+ }
2307
+ const logger = createLogger("Service:StatsSnapshotStore");
2308
+ const SNAPSHOT_VERSION = 1;
2309
+ function isRecord(value) {
2310
+ return typeof value === "object" && value !== null;
2311
+ }
2312
+ function isFiniteNumber(value) {
2313
+ return typeof value === "number" && Number.isFinite(value);
2314
+ }
2315
+ function isString(value) {
2316
+ return typeof value === "string";
2317
+ }
2318
+ function isTokenTotals(value) {
2319
+ return isRecord(value) && isFiniteNumber(value.totalTokens) && isFiniteNumber(value.inputTokens) && isFiniteNumber(value.outputTokens) && isFiniteNumber(value.cachedTokens) && isFiniteNumber(value.reasoningTokens);
2320
+ }
2321
+ function isDailyBucket(value) {
2322
+ return isRecord(value) && isString(value.date) && isFiniteNumber(value.eventCount) && isTokenTotals(value);
2323
+ }
2324
+ function isHourlyBucket(value) {
2325
+ return isRecord(value) && isFiniteNumber(value.hour) && isFiniteNumber(value.eventCount) && isTokenTotals(value);
2326
+ }
2327
+ function isModelTotals(value) {
2328
+ return isRecord(value) && isString(value.model) && isString(value.reasoningEffort) && isTokenTotals(value);
2329
+ }
2330
+ function isSessionModelUsage(value) {
2331
+ return isRecord(value) && isString(value.model) && isString(value.reasoningEffort);
2332
+ }
2333
+ function isSessionRecord(value) {
2334
+ return isRecord(value) && (!("tokenComputationVersion" in value) || isFiniteNumber(value.tokenComputationVersion)) && isString(value.sessionId) && isString(value.filePath) && isString(value.revision) && typeof value.archived === "boolean" && isString(value.cwd) && isString(value.startTime) && isString(value.lastActivity) && isFiniteNumber(value.eventCount) && isFiniteNumber(value.turnCount) && isFiniteNumber(value.toolCallCount) && isFiniteNumber(value.durationMs) && Array.isArray(value.modelUsages) && value.modelUsages.every(isSessionModelUsage) && isTokenTotals(value.tokens) && Array.isArray(value.modelTokenTotals) && value.modelTokenTotals.every(isModelTotals) && Array.isArray(value.dailyBuckets) && value.dailyBuckets.every(isDailyBucket) && Array.isArray(value.hourlyBuckets) && value.hourlyBuckets.every(isHourlyBucket) && isString(value.lastSeenAt);
2335
+ }
2336
+ function isSnapshotFile(value) {
2337
+ return isRecord(value) && isFiniteNumber(value.version) && Array.isArray(value.sessions) && value.sessions.every(isSessionRecord);
2338
+ }
2339
+ function cloneSessions(rows) {
2340
+ return structuredClone(rows);
2341
+ }
2342
+ class StatsSnapshotStore {
2343
+ constructor(filePath) {
2344
+ this.filePath = filePath;
2345
+ }
2346
+ async getSessions() {
2347
+ const snapshot = await this.readSnapshot();
2348
+ return cloneSessions(snapshot.sessions);
2349
+ }
2350
+ async saveSessions(rows) {
2351
+ const snapshot = {
2352
+ version: SNAPSHOT_VERSION,
2353
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2354
+ sessions: cloneSessions(rows)
2355
+ };
2356
+ const directory = path__namespace.dirname(this.filePath);
2357
+ await fs.promises.mkdir(directory, { recursive: true });
2358
+ const tempPath = `${this.filePath}.tmp-${process.pid}-${Date.now()}`;
2359
+ await fs.promises.writeFile(tempPath, JSON.stringify(snapshot, null, 2), "utf8");
2360
+ await fs.promises.rename(tempPath, this.filePath);
2361
+ }
2362
+ async readSnapshot() {
2363
+ try {
2364
+ const raw = await fs.promises.readFile(this.filePath, "utf8");
2365
+ const parsed = JSON.parse(raw);
2366
+ if (!isSnapshotFile(parsed) || parsed.version !== SNAPSHOT_VERSION) {
2367
+ return {
2368
+ version: SNAPSHOT_VERSION,
2369
+ updatedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
2370
+ sessions: []
2371
+ };
2372
+ }
2373
+ return parsed;
2374
+ } catch (error) {
2375
+ if (error.code !== "ENOENT") {
2376
+ logger.warn("Failed to read stats snapshot, using empty snapshot", error);
2377
+ }
2378
+ return {
2379
+ version: SNAPSHOT_VERSION,
2380
+ updatedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
2381
+ sessions: []
2382
+ };
2383
+ }
2384
+ }
2385
+ }
1790
2386
  const DETAIL_CACHE_PREFIX = "detail-v2";
1791
2387
  const CHUNKS_CACHE_PREFIX = "chunks-v2";
1792
2388
  const SESSIONS_CACHE_PREFIX = "sessions";
2389
+ const STATS_CACHE_PREFIX = "stats-v1";
1793
2390
  const UNKNOWN_REVISION = "unknown-revision";
2391
+ const TOKEN_COMPUTATION_VERSION = 2;
1794
2392
  function hasCompactionSignals(entries) {
1795
2393
  return entries.some(
1796
2394
  (entry) => isCompactedEntry(entry) || isCompactionEntry(entry) || isEventMsgEntry(entry) && isContextCompactedPayload(entry.payload)
@@ -1857,15 +2455,53 @@ function buildProjectsFromSessions(sessions) {
1857
2455
  (a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime()
1858
2456
  );
1859
2457
  }
2458
+ function normalizeStatsScope(scope) {
2459
+ if (!scope || scope.type === "all") {
2460
+ return { type: "all" };
2461
+ }
2462
+ const cwd = scope.cwd?.trim();
2463
+ if (!cwd) {
2464
+ return { type: "all" };
2465
+ }
2466
+ return {
2467
+ type: "project",
2468
+ cwd
2469
+ };
2470
+ }
2471
+ function serializeStatsScope(scope) {
2472
+ if (scope.type === "all") {
2473
+ return "all";
2474
+ }
2475
+ return `project:${scope.cwd}`;
2476
+ }
2477
+ function buildStatsSessionSignature(sessions, revisionByFilePath) {
2478
+ const hash = node_crypto.createHash("sha1");
2479
+ const sortedSessions = [...sessions].sort(
2480
+ (left, right) => left.filePath.localeCompare(right.filePath) || left.id.localeCompare(right.id)
2481
+ );
2482
+ for (const session of sortedSessions) {
2483
+ hash.update(session.id);
2484
+ hash.update("\0");
2485
+ hash.update(session.filePath);
2486
+ hash.update("\0");
2487
+ hash.update(revisionByFilePath.get(session.filePath) ?? UNKNOWN_REVISION);
2488
+ hash.update("\n");
2489
+ }
2490
+ return hash.digest("hex");
2491
+ }
1860
2492
  class CodexServiceContext {
1861
2493
  constructor(options = {}) {
1862
2494
  const sessionsPath = options.sessionsPath ?? process.env.CODEX_SESSIONS_PATH;
2495
+ const configPath = options.configPath ?? DEFAULT_CODEX_DEVTOOLS_CONFIG_PATH;
1863
2496
  this.scanner = new CodexSessionScanner(sessionsPath);
1864
2497
  this.parser = new CodexSessionParser();
1865
2498
  this.chunkBuilder = new CodexChunkBuilder();
1866
2499
  this.dataCache = new DataCache(options.cacheSize ?? 200, options.cacheTtlMinutes ?? 10);
1867
2500
  this.watcher = new FileWatcher(sessionsPath);
1868
- this.configManager = new ConfigManager(options.configPath);
2501
+ this.configManager = new ConfigManager(configPath);
2502
+ const metadataDirectory = path__namespace.dirname(configPath);
2503
+ this.statsSnapshotStore = new StatsSnapshotStore(path__namespace.join(metadataDirectory, "stats-snapshot.json"));
2504
+ this.modelRatesStore = new ModelRatesStore(path__namespace.join(metadataDirectory, "stats-rates.json"));
1869
2505
  this.removeFileChangeListener = this.watcher.onFileChange(() => {
1870
2506
  this.dataCache.clear();
1871
2507
  });
@@ -1983,6 +2619,33 @@ class CodexServiceContext {
1983
2619
  results
1984
2620
  };
1985
2621
  }
2622
+ async getModelRates() {
2623
+ return this.modelRatesStore.getRateCard();
2624
+ }
2625
+ async refreshModelRates() {
2626
+ const refreshed = await this.modelRatesStore.refreshFromPricingPage();
2627
+ this.dataCache.clear();
2628
+ return refreshed;
2629
+ }
2630
+ async getStats(scope = { type: "all" }) {
2631
+ const normalizedScope = normalizeStatsScope(scope);
2632
+ const rates = await this.getModelRates();
2633
+ const sessions = await this.scanner.scanSessions();
2634
+ const revisionByFilePath = await this.getSessionRevisionLookup(sessions);
2635
+ const sessionSignature = buildStatsSessionSignature(sessions, revisionByFilePath);
2636
+ const cacheKey = DataCache.buildKey(
2637
+ STATS_CACHE_PREFIX,
2638
+ `${serializeStatsScope(normalizedScope)}:${rates.updatedAt ?? "never"}:${sessionSignature}`
2639
+ );
2640
+ const cached = this.dataCache.get(cacheKey);
2641
+ if (cached) {
2642
+ return cached;
2643
+ }
2644
+ const reconciledRecords = await this.reconcileStatsSnapshot(sessions, revisionByFilePath);
2645
+ const summary = aggregateStatsSummary(reconciledRecords, normalizedScope, rates);
2646
+ this.dataCache.set(cacheKey, summary);
2647
+ return summary;
2648
+ }
1986
2649
  getConfig() {
1987
2650
  return this.configManager.getConfig();
1988
2651
  }
@@ -1999,6 +2662,75 @@ class CodexServiceContext {
1999
2662
  value
2000
2663
  );
2001
2664
  }
2665
+ async reconcileStatsSnapshot(currentSessions, revisionByFilePath) {
2666
+ const nowIso = (/* @__PURE__ */ new Date()).toISOString();
2667
+ const existingRecords = await this.statsSnapshotStore.getSessions();
2668
+ const existingBySessionId = new Map(existingRecords.map((record) => [record.sessionId, record]));
2669
+ const nextRecords = [];
2670
+ const activeSessionIds = /* @__PURE__ */ new Set();
2671
+ for (const session of currentSessions) {
2672
+ activeSessionIds.add(session.id);
2673
+ const revision = revisionByFilePath.get(session.filePath) ?? UNKNOWN_REVISION;
2674
+ const existing = existingBySessionId.get(session.id);
2675
+ if (existing && existing.revision === revision && existing.tokenComputationVersion === TOKEN_COMPUTATION_VERSION) {
2676
+ nextRecords.push({
2677
+ ...existing,
2678
+ archived: false,
2679
+ revision,
2680
+ filePath: session.filePath,
2681
+ cwd: session.cwd,
2682
+ startTime: session.startTime,
2683
+ modelUsages: session.modelUsages.length > 0 ? session.modelUsages : existing.modelUsages,
2684
+ lastSeenAt: nowIso
2685
+ });
2686
+ continue;
2687
+ }
2688
+ let detail = null;
2689
+ try {
2690
+ detail = await this.getSessionDetail(session.id);
2691
+ } catch {
2692
+ detail = null;
2693
+ }
2694
+ if (!detail) {
2695
+ if (existing) {
2696
+ nextRecords.push({
2697
+ ...existing,
2698
+ archived: false,
2699
+ revision,
2700
+ filePath: session.filePath,
2701
+ cwd: session.cwd,
2702
+ startTime: session.startTime,
2703
+ lastSeenAt: nowIso
2704
+ });
2705
+ }
2706
+ continue;
2707
+ }
2708
+ const nextRecord = buildSessionStatsRecord(detail, revision, nowIso);
2709
+ nextRecord.archived = false;
2710
+ nextRecords.push(nextRecord);
2711
+ }
2712
+ for (const record of existingRecords) {
2713
+ if (activeSessionIds.has(record.sessionId)) {
2714
+ continue;
2715
+ }
2716
+ nextRecords.push({
2717
+ ...record,
2718
+ archived: true
2719
+ });
2720
+ }
2721
+ nextRecords.sort((a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime());
2722
+ await this.statsSnapshotStore.saveSessions(nextRecords);
2723
+ return nextRecords;
2724
+ }
2725
+ async getSessionRevisionLookup(sessions) {
2726
+ const revisions = await Promise.all(
2727
+ sessions.map(async (session) => {
2728
+ const revision = await this.getSessionFileRevision(session.filePath);
2729
+ return [session.filePath, revision];
2730
+ })
2731
+ );
2732
+ return new Map(revisions);
2733
+ }
2002
2734
  async findSessionById(sessionId) {
2003
2735
  const sessions = await this.getAllSessions();
2004
2736
  const match = sessions.find((session) => session.id === sessionId);