@vibe-cafe/vibe-usage 0.9.16 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/package.json +1 -1
- package/src/claude-roots.js +81 -0
- package/src/daemon-service.js +23 -4
- package/src/parsers/claude-code.js +250 -188
- package/src/parsers/codex-cache.js +138 -0
- package/src/parsers/codex.js +601 -144
- package/src/sync.js +43 -10
- package/src/tools.js +1 -14
package/src/parsers/codex.js
CHANGED
|
@@ -1,9 +1,25 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
createReadStream,
|
|
4
|
+
existsSync,
|
|
5
|
+
openSync,
|
|
6
|
+
readSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
statSync,
|
|
9
|
+
} from 'node:fs';
|
|
2
10
|
import { join } from 'node:path';
|
|
3
11
|
import { homedir } from 'node:os';
|
|
4
12
|
import { createInterface } from 'node:readline';
|
|
5
13
|
import { createHash } from 'node:crypto';
|
|
6
|
-
import { aggregateToBuckets
|
|
14
|
+
import { aggregateToBuckets } from './index.js';
|
|
15
|
+
import {
|
|
16
|
+
codexCacheEnabled,
|
|
17
|
+
fileSignature,
|
|
18
|
+
loadCodexFileCache,
|
|
19
|
+
loadCodexFileTail,
|
|
20
|
+
saveCodexFileCache,
|
|
21
|
+
saveCodexFileTail,
|
|
22
|
+
} from './codex-cache.js';
|
|
7
23
|
|
|
8
24
|
// Codex stores live sessions in $CODEX_HOME/sessions (default ~/.codex) and,
|
|
9
25
|
// once a session is "completed", moves its rollout file verbatim into
|
|
@@ -12,8 +28,11 @@ import { aggregateToBuckets, extractSessions } from './index.js';
|
|
|
12
28
|
// both, index them together so fork replay-skip works across directories, and
|
|
13
29
|
// select the most complete physical file when the same session briefly exists
|
|
14
30
|
// in both locations during an archive move.
|
|
15
|
-
function
|
|
16
|
-
|
|
31
|
+
function getCodexHome() {
|
|
32
|
+
return process.env.CODEX_HOME?.trim() || join(homedir(), '.codex');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sessionsDirs(codexHome) {
|
|
17
36
|
return [
|
|
18
37
|
join(codexHome, 'sessions'),
|
|
19
38
|
join(codexHome, 'archived_sessions'),
|
|
@@ -42,13 +61,14 @@ function findJsonlFiles(dir) {
|
|
|
42
61
|
return results;
|
|
43
62
|
}
|
|
44
63
|
|
|
45
|
-
function readLines(filePath, snapshotSize) {
|
|
64
|
+
function readLines(filePath, snapshotSize, start = 0) {
|
|
46
65
|
return createInterface({
|
|
47
66
|
input: createReadStream(filePath, {
|
|
48
67
|
encoding: 'utf-8',
|
|
49
68
|
// Rollouts are append-only while Codex is working. Bound both parser
|
|
50
69
|
// passes to the size captured before pass 1 so they see the same prefix
|
|
51
70
|
// even when the live file grows between reads.
|
|
71
|
+
...(start > 0 ? { start } : {}),
|
|
52
72
|
...(snapshotSize == null ? {} : { end: snapshotSize - 1 }),
|
|
53
73
|
}),
|
|
54
74
|
crlfDelay: Infinity,
|
|
@@ -85,6 +105,84 @@ function extractParentThreadId(meta) {
|
|
|
85
105
|
|| null;
|
|
86
106
|
}
|
|
87
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Read only far enough to find the canonical (first) session_meta. This cheap
|
|
110
|
+
* discovery pass lets ordinary sessions skip the old full-file index pass;
|
|
111
|
+
* only forks, sub-agents, and parents referenced by them need replay indexes.
|
|
112
|
+
*/
|
|
113
|
+
async function readSessionHeader(filePath, snapshotSize) {
|
|
114
|
+
for await (const line of readLines(filePath, snapshotSize)) {
|
|
115
|
+
if (!line.trim()) continue;
|
|
116
|
+
try {
|
|
117
|
+
const obj = JSON.parse(line);
|
|
118
|
+
if (obj.type !== 'session_meta' || !obj.payload) continue;
|
|
119
|
+
const meta = obj.payload;
|
|
120
|
+
return {
|
|
121
|
+
sessionId: meta.id || null,
|
|
122
|
+
forkedFromId: meta.forked_from_id || null,
|
|
123
|
+
parentThreadId: extractParentThreadId(meta),
|
|
124
|
+
sessionProject: extractProject(meta),
|
|
125
|
+
sessionStartedAtMs: timestampMs(meta.timestamp) ?? timestampMs(obj.timestamp),
|
|
126
|
+
isSubagent: isSubagentMeta(meta),
|
|
127
|
+
};
|
|
128
|
+
} catch {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
sessionId: null,
|
|
134
|
+
forkedFromId: null,
|
|
135
|
+
parentThreadId: null,
|
|
136
|
+
sessionProject: 'unknown',
|
|
137
|
+
sessionStartedAtMs: null,
|
|
138
|
+
isSubagent: false,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function cacheData(cache, changes = {}) {
|
|
143
|
+
return {
|
|
144
|
+
header: changes.header ?? cache?.header ?? null,
|
|
145
|
+
index: changes.index ?? cache?.index ?? null,
|
|
146
|
+
result: changes.result ?? cache?.result ?? null,
|
|
147
|
+
lastAuditedAt: changes.lastAuditedAt ?? cache?.lastAuditedAt ?? null,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const TAIL_GUARD_BYTES = 4096;
|
|
152
|
+
|
|
153
|
+
function snapshotGuard(filePath, size) {
|
|
154
|
+
if (size <= 0) return { hash: null, endsWithNewline: false };
|
|
155
|
+
const length = Math.min(size, TAIL_GUARD_BYTES);
|
|
156
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
157
|
+
const fd = openSync(filePath, 'r');
|
|
158
|
+
try {
|
|
159
|
+
const read = readSync(fd, buffer, 0, length, size - length);
|
|
160
|
+
const slice = buffer.subarray(0, read);
|
|
161
|
+
return {
|
|
162
|
+
hash: createHash('sha256').update(slice).digest('base64url').slice(0, 20),
|
|
163
|
+
endsWithNewline: slice.at(-1) === 0x0a,
|
|
164
|
+
};
|
|
165
|
+
} finally {
|
|
166
|
+
closeSync(fd);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function tailStateFor(file) {
|
|
171
|
+
const prior = file.priorTail;
|
|
172
|
+
const tail = prior?.tail;
|
|
173
|
+
if (!tail || !prior.signature) return null;
|
|
174
|
+
if (prior.signature.size <= 0 || prior.signature.size >= file.signature.size) return null;
|
|
175
|
+
if (prior.signature.dev !== file.signature.dev || prior.signature.ino !== file.signature.ino) return null;
|
|
176
|
+
if (prior.signature.mtimeMs > file.signature.mtimeMs) return null;
|
|
177
|
+
if (tail.parsedBytes !== prior.signature.size || !tail.endsWithNewline || !tail.guardHash) return null;
|
|
178
|
+
try {
|
|
179
|
+
const guard = snapshotGuard(file.filePath, prior.signature.size);
|
|
180
|
+
return guard.hash === tail.guardHash ? tail : null;
|
|
181
|
+
} catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
88
186
|
function timestampMs(value) {
|
|
89
187
|
if (value == null || value === '') return null;
|
|
90
188
|
const n = new Date(value).getTime();
|
|
@@ -354,127 +452,223 @@ function replayBoundary(meta, sessionById) {
|
|
|
354
452
|
return { rawTokenCount: 0, recordIndex: null };
|
|
355
453
|
}
|
|
356
454
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
455
|
+
function boundaryKey(boundary) {
|
|
456
|
+
return `${boundary.rawTokenCount}:${boundary.recordIndex ?? ''}`;
|
|
457
|
+
}
|
|
360
458
|
|
|
361
|
-
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
} catch {
|
|
369
|
-
// The file may move to archived_sessions between discovery and stat.
|
|
370
|
-
// Its archived copy will be picked up on the next sync.
|
|
371
|
-
}
|
|
459
|
+
function updateFileCache(codexHome, file, changes) {
|
|
460
|
+
const data = cacheData(file.cache, changes);
|
|
461
|
+
try {
|
|
462
|
+
saveCodexFileCache(codexHome, file.filePath, file.signature, data);
|
|
463
|
+
} catch {
|
|
464
|
+
// A read-only home, full disk, or antivirus race must only disable the
|
|
465
|
+
// optimization for this run. Raw-log parsing remains the source of truth.
|
|
372
466
|
}
|
|
373
|
-
|
|
467
|
+
file.cache = { ...(file.cache || {}), ...data };
|
|
468
|
+
}
|
|
374
469
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
470
|
+
function workBudgetMs() {
|
|
471
|
+
const configured = Number(process.env.VIBE_USAGE_CODEX_WORK_BUDGET_MS);
|
|
472
|
+
if (Number.isFinite(configured) && configured > 0) return configured;
|
|
473
|
+
// The macOS app terminates its child after 120 seconds. Cache-building work
|
|
474
|
+
// in a non-interactive child therefore checkpoints before that wall so the
|
|
475
|
+
// next invocation resumes instead of starting from zero. Interactive users
|
|
476
|
+
// can let a cold build finish in one run (and can interrupt it safely).
|
|
477
|
+
if (codexCacheEnabled() && !process.stdout.isTTY) return 105_000;
|
|
478
|
+
return Number.POSITIVE_INFINITY;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function auditIntervalMs() {
|
|
482
|
+
const configured = Number(process.env.VIBE_USAGE_CODEX_AUDIT_INTERVAL_MS);
|
|
483
|
+
if (Number.isFinite(configured) && configured >= 0) return configured;
|
|
484
|
+
return 30 * 24 * 60 * 60 * 1000;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function auditMaxBytes() {
|
|
488
|
+
const configured = Number(process.env.VIBE_USAGE_CODEX_AUDIT_MAX_BYTES);
|
|
489
|
+
if (Number.isFinite(configured) && configured > 0) return configured;
|
|
490
|
+
// Keep the background audit bounded below the app's wall timeout. Larger
|
|
491
|
+
// active files are still invalidated immediately by their stat signature,
|
|
492
|
+
// and every cache generation is rebuilt after parser-algorithm changes.
|
|
493
|
+
return 64 * 1024 * 1024;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function applySessionEvent(acc, event) {
|
|
497
|
+
const timestampMsValue = event.timestamp.getTime();
|
|
498
|
+
if (acc.lastTimestampMs != null && timestampMsValue < acc.lastTimestampMs) return false;
|
|
499
|
+
acc.firstTimestampMs ??= timestampMsValue;
|
|
500
|
+
acc.lastTimestampMs = timestampMsValue;
|
|
501
|
+
acc.messageCount++;
|
|
502
|
+
|
|
503
|
+
if (event.role === 'user') {
|
|
504
|
+
if (acc.turnStartMs != null && acc.turnEndMs != null && acc.turnEndMs > acc.turnStartMs) {
|
|
505
|
+
acc.completedActiveSeconds += Math.round((acc.turnEndMs - acc.turnStartMs) / 1000);
|
|
386
506
|
}
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
507
|
+
acc.turnStartMs = null;
|
|
508
|
+
acc.turnEndMs = null;
|
|
509
|
+
acc.waitingForFirstResponse = true;
|
|
510
|
+
acc.userMessageCount++;
|
|
511
|
+
acc.userPromptHours[event.timestamp.getUTCHours()]++;
|
|
512
|
+
} else if (acc.waitingForFirstResponse) {
|
|
513
|
+
acc.turnStartMs = timestampMsValue;
|
|
514
|
+
acc.turnEndMs = timestampMsValue;
|
|
515
|
+
acc.waitingForFirstResponse = false;
|
|
516
|
+
} else if (acc.turnStartMs != null) {
|
|
517
|
+
acc.turnEndMs = timestampMsValue;
|
|
518
|
+
}
|
|
519
|
+
return true;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function buildSessionAccumulator(events, previous = null) {
|
|
523
|
+
const sorted = [...events].sort((a, b) => a.timestamp - b.timestamp);
|
|
524
|
+
const first = sorted[0];
|
|
525
|
+
const acc = previous
|
|
526
|
+
? {
|
|
527
|
+
...previous,
|
|
528
|
+
userPromptHours: [...previous.userPromptHours],
|
|
392
529
|
}
|
|
530
|
+
: {
|
|
531
|
+
sessionId: first?.sessionId || null,
|
|
532
|
+
source: first?.source || null,
|
|
533
|
+
project: first?.project || 'unknown',
|
|
534
|
+
firstTimestampMs: null,
|
|
535
|
+
lastTimestampMs: null,
|
|
536
|
+
completedActiveSeconds: 0,
|
|
537
|
+
turnStartMs: null,
|
|
538
|
+
turnEndMs: null,
|
|
539
|
+
waitingForFirstResponse: false,
|
|
540
|
+
messageCount: 0,
|
|
541
|
+
userMessageCount: 0,
|
|
542
|
+
userPromptHours: new Array(24).fill(0),
|
|
543
|
+
};
|
|
544
|
+
for (const event of sorted) {
|
|
545
|
+
if (!applySessionEvent(acc, event)) return null;
|
|
546
|
+
}
|
|
547
|
+
return acc.sessionId ? acc : null;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function sessionFromAccumulator(acc) {
|
|
551
|
+
if (!acc?.sessionId || acc.firstTimestampMs == null || acc.lastTimestampMs == null) return null;
|
|
552
|
+
let activeSeconds = acc.completedActiveSeconds;
|
|
553
|
+
if (acc.turnStartMs != null && acc.turnEndMs != null && acc.turnEndMs > acc.turnStartMs) {
|
|
554
|
+
activeSeconds += Math.round((acc.turnEndMs - acc.turnStartMs) / 1000);
|
|
555
|
+
}
|
|
556
|
+
return {
|
|
557
|
+
source: acc.source,
|
|
558
|
+
project: acc.project || 'unknown',
|
|
559
|
+
sessionHash: createHash('sha256').update(acc.sessionId).digest('hex').slice(0, 16),
|
|
560
|
+
firstMessageAt: new Date(acc.firstTimestampMs).toISOString(),
|
|
561
|
+
lastMessageAt: new Date(acc.lastTimestampMs).toISOString(),
|
|
562
|
+
durationSeconds: Math.round((acc.lastTimestampMs - acc.firstTimestampMs) / 1000),
|
|
563
|
+
activeSeconds,
|
|
564
|
+
messageCount: acc.messageCount,
|
|
565
|
+
userMessageCount: acc.userMessageCount,
|
|
566
|
+
userPromptHours: acc.userPromptHours,
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function mergeBucketLists(lists) {
|
|
571
|
+
const entries = [];
|
|
572
|
+
for (const buckets of lists) {
|
|
573
|
+
for (const bucket of buckets || []) {
|
|
574
|
+
entries.push({
|
|
575
|
+
source: bucket.source,
|
|
576
|
+
model: bucket.model,
|
|
577
|
+
project: bucket.project,
|
|
578
|
+
...(bucket.hostname ? { hostname: bucket.hostname } : {}),
|
|
579
|
+
timestamp: new Date(bucket.bucketStart),
|
|
580
|
+
inputTokens: bucket.inputTokens,
|
|
581
|
+
outputTokens: bucket.outputTokens,
|
|
582
|
+
cachedInputTokens: bucket.cachedInputTokens,
|
|
583
|
+
reasoningOutputTokens: bucket.reasoningOutputTokens,
|
|
584
|
+
});
|
|
393
585
|
}
|
|
394
586
|
}
|
|
587
|
+
return aggregateToBuckets(entries);
|
|
588
|
+
}
|
|
395
589
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
590
|
+
async function parseSessionFile(filePath, snapshotSize, fm, boundary, {
|
|
591
|
+
previousTail = null,
|
|
592
|
+
captureTail = false,
|
|
593
|
+
} = {}) {
|
|
594
|
+
const entries = [];
|
|
595
|
+
const sessionEvents = [];
|
|
596
|
+
let rawTokenSeen = previousTail?.rawTokenSeen || 0;
|
|
597
|
+
let parsedRecordIndex = previousTail?.parsedRecordIndex || 0;
|
|
598
|
+
let firstSessionMetaSeen = previousTail?.firstSessionMetaSeen || false;
|
|
599
|
+
|
|
600
|
+
const sessionProject = fm.sessionProject;
|
|
601
|
+
// Group timing events by the real Codex session id, not the file path: the
|
|
602
|
+
// same session can briefly exist in both sessions/ and archived_sessions/
|
|
603
|
+
// (mid-archive, or a re-synced archive). Path-keyed grouping would emit it
|
|
604
|
+
// as two different sessionHashes and double-count its session stats. Fall
|
|
605
|
+
// back to the path only when the id is unknown (corrupt/missing meta).
|
|
606
|
+
const sessionKey = fm.sessionId || filePath;
|
|
607
|
+
|
|
608
|
+
let turnContextModel = previousTail?.turnContextModel || 'unknown';
|
|
609
|
+
let prevTotal = previousTail?.prevTotal || null;
|
|
610
|
+
let prevCumulativeTotal = previousTail?.prevCumulativeTotal ?? null;
|
|
611
|
+
const start = previousTail?.parsedBytes || 0;
|
|
612
|
+
for await (const line of readLines(filePath, snapshotSize, start)) {
|
|
613
|
+
if (!line.trim()) continue;
|
|
614
|
+
try {
|
|
615
|
+
const obj = JSON.parse(line);
|
|
616
|
+
parsedRecordIndex++;
|
|
423
617
|
|
|
424
618
|
// A direct child task boundary covers every copied record, including
|
|
425
619
|
// timing/meta events. The raw-token ordinal covers full-history and
|
|
426
620
|
// last-N-turn forks whose exact payload sequence was matched in pass 1.
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
621
|
+
const beforeOwnTask = boundary.recordIndex != null
|
|
622
|
+
&& parsedRecordIndex < boundary.recordIndex;
|
|
623
|
+
const inReplayBlock = beforeOwnTask || rawTokenSeen < boundary.rawTokenCount;
|
|
624
|
+
|
|
625
|
+
const isSessionMeta = obj.type === 'session_meta';
|
|
626
|
+
const isCanonicalSessionMeta = isSessionMeta && !firstSessionMetaSeen;
|
|
627
|
+
const isOwnSessionMeta = isSessionMeta
|
|
628
|
+
&& obj.payload?.id != null
|
|
629
|
+
&& obj.payload.id === fm.sessionId;
|
|
630
|
+
if (isSessionMeta) firstSessionMetaSeen = true;
|
|
631
|
+
|
|
632
|
+
if (obj.timestamp) {
|
|
633
|
+
const evTs = new Date(obj.timestamp);
|
|
634
|
+
if (!isNaN(evTs.getTime())) {
|
|
441
635
|
// Repeated same-id metadata can be appended on resume/config
|
|
442
636
|
// updates and belongs to this logical session. A different-id meta
|
|
443
637
|
// is copied parent history and must not inflate timing stats.
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
}
|
|
638
|
+
const keepSessionMeta = isCanonicalSessionMeta
|
|
639
|
+
|| (isOwnSessionMeta && !inReplayBlock);
|
|
640
|
+
if (keepSessionMeta || (!isSessionMeta && !inReplayBlock)) {
|
|
641
|
+
const isUserTurn = obj.type === 'turn_context' || obj.type === 'session_meta';
|
|
642
|
+
sessionEvents.push({
|
|
643
|
+
sessionId: sessionKey,
|
|
644
|
+
source: 'codex',
|
|
645
|
+
project: sessionProject,
|
|
646
|
+
timestamp: evTs,
|
|
647
|
+
role: isUserTurn ? 'user' : 'assistant',
|
|
648
|
+
});
|
|
456
649
|
}
|
|
457
650
|
}
|
|
651
|
+
}
|
|
458
652
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
653
|
+
if (obj.type === 'turn_context' && obj.payload?.model) {
|
|
654
|
+
turnContextModel = obj.payload.model;
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
463
657
|
|
|
464
|
-
|
|
658
|
+
if (obj.type !== 'event_msg') continue;
|
|
465
659
|
|
|
466
|
-
|
|
467
|
-
|
|
660
|
+
const payload = obj.payload;
|
|
661
|
+
if (!payload) continue;
|
|
468
662
|
|
|
469
|
-
|
|
663
|
+
if (payload.type !== 'token_count') continue;
|
|
470
664
|
|
|
471
665
|
// Raw ordinals advance before validating usage/timestamp so pass 1 and
|
|
472
666
|
// pass 2 cannot drift on a malformed copied token_count record.
|
|
473
|
-
|
|
474
|
-
|
|
667
|
+
const isReplayedHistory = inReplayBlock;
|
|
668
|
+
rawTokenSeen++;
|
|
475
669
|
|
|
476
|
-
|
|
477
|
-
|
|
670
|
+
const info = payload.info;
|
|
671
|
+
if (!info) continue;
|
|
478
672
|
|
|
479
673
|
// Codex sometimes writes the same token_count twice back-to-back:
|
|
480
674
|
// identical last_token_usage with an unchanged cumulative total. A
|
|
@@ -484,71 +678,334 @@ export async function parse() {
|
|
|
484
678
|
// compaction — and must count as zero, not a second copy of
|
|
485
679
|
// last_token_usage. Guarded to positive totals so builds that leave
|
|
486
680
|
// total_token_usage all-zero can't suppress real usage.
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
681
|
+
const cumulativeTotal = info.total_token_usage?.total_tokens;
|
|
682
|
+
const isDuplicateEmission = typeof cumulativeTotal === 'number'
|
|
683
|
+
&& cumulativeTotal > 0
|
|
684
|
+
&& cumulativeTotal === prevCumulativeTotal;
|
|
685
|
+
if (typeof cumulativeTotal === 'number') prevCumulativeTotal = cumulativeTotal;
|
|
492
686
|
|
|
493
687
|
// Prefer incremental per-request usage; compute delta from cumulative
|
|
494
688
|
// totals as fallback. Always advance the cumulative baseline, even
|
|
495
689
|
// when last_token_usage exists or the record belongs to a replay.
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
690
|
+
const curr = info.total_token_usage;
|
|
691
|
+
let usage = info.last_token_usage;
|
|
692
|
+
if (!usage && curr) {
|
|
693
|
+
if (prevTotal) {
|
|
694
|
+
const delta = {
|
|
695
|
+
input_tokens: (curr.input_tokens || 0) - (prevTotal.input_tokens || 0),
|
|
696
|
+
output_tokens: (curr.output_tokens || 0) - (prevTotal.output_tokens || 0),
|
|
697
|
+
cached_input_tokens: (curr.cached_input_tokens || 0) - (prevTotal.cached_input_tokens || 0),
|
|
698
|
+
reasoning_output_tokens: (curr.reasoning_output_tokens || 0) - (prevTotal.reasoning_output_tokens || 0),
|
|
699
|
+
};
|
|
506
700
|
// Cumulative counters can reset after compaction or a new usage
|
|
507
701
|
// window. Treat the first post-reset total as a fresh baseline;
|
|
508
702
|
// allowing a negative delta would cancel legitimate bucket usage.
|
|
509
|
-
|
|
510
|
-
|
|
703
|
+
usage = Object.values(delta).some(value => value < 0) ? curr : delta;
|
|
704
|
+
} else {
|
|
511
705
|
// First cumulative entry — use as-is (it's the first event's total)
|
|
512
|
-
|
|
513
|
-
}
|
|
706
|
+
usage = curr;
|
|
514
707
|
}
|
|
708
|
+
}
|
|
515
709
|
// total_token_usage is session-wide, not per model. A global baseline
|
|
516
710
|
// avoids counting the full cumulative total again after a model switch.
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
711
|
+
if (curr) prevTotal = { ...curr };
|
|
712
|
+
if (!usage) continue;
|
|
713
|
+
if (isReplayedHistory || isDuplicateEmission) continue;
|
|
520
714
|
|
|
521
|
-
|
|
522
|
-
|
|
715
|
+
const timestamp = obj.timestamp ? new Date(obj.timestamp) : null;
|
|
716
|
+
if (!timestamp || isNaN(timestamp.getTime())) continue;
|
|
523
717
|
|
|
524
|
-
|
|
718
|
+
const model = info.model || payload.model || turnContextModel || 'unknown';
|
|
525
719
|
|
|
526
720
|
// OpenAI API: input_tokens INCLUDES cached, output_tokens INCLUDES reasoning.
|
|
527
721
|
// Normalize to Anthropic-style semantics where each field is non-overlapping.
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
722
|
+
const cachedInput = usage.cached_input_tokens || usage.cache_read_input_tokens || 0;
|
|
723
|
+
const reasoningOutput = usage.reasoning_output_tokens || 0;
|
|
724
|
+
entries.push({
|
|
725
|
+
source: 'codex',
|
|
726
|
+
model,
|
|
727
|
+
project: sessionProject,
|
|
728
|
+
timestamp,
|
|
729
|
+
inputTokens: (usage.input_tokens || 0) - cachedInput,
|
|
730
|
+
outputTokens: (usage.output_tokens || 0) - reasoningOutput,
|
|
731
|
+
cachedInputTokens: cachedInput,
|
|
732
|
+
reasoningOutputTokens: reasoningOutput,
|
|
733
|
+
});
|
|
734
|
+
} catch {
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Indexed files must match both passes exactly. Ordinary sessions take the
|
|
740
|
+
// single-pass fast path and have no expected counts; their byte-bounded
|
|
741
|
+
// snapshot is still stable, and any append invalidates the stat signature on
|
|
742
|
+
// the next sync.
|
|
743
|
+
if (fm.parsedRecordCount != null && fm.rawTokenCount != null) {
|
|
744
|
+
if (parsedRecordIndex !== fm.parsedRecordCount || rawTokenSeen !== fm.rawTokenCount) {
|
|
745
|
+
throw new Error('Codex rollout changed while syncing; retry on the next sync');
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const buckets = mergeBucketLists([
|
|
750
|
+
previousTail?.buckets || [],
|
|
751
|
+
aggregateToBuckets(entries),
|
|
752
|
+
]);
|
|
753
|
+
const sessionAccumulator = buildSessionAccumulator(
|
|
754
|
+
sessionEvents,
|
|
755
|
+
previousTail?.sessionAccumulator || null
|
|
756
|
+
);
|
|
757
|
+
// Appended records should be chronological. If an app version inserts an
|
|
758
|
+
// older event into the tail, the compact accumulator cannot reproduce the
|
|
759
|
+
// global sort exactly, so discard the optimization and rebuild this file.
|
|
760
|
+
if (previousTail && sessionEvents.length > 0 && !sessionAccumulator) {
|
|
761
|
+
return parseSessionFile(filePath, snapshotSize, fm, boundary, { captureTail });
|
|
762
|
+
}
|
|
763
|
+
const session = sessionFromAccumulator(sessionAccumulator);
|
|
764
|
+
const result = { buckets, sessions: session ? [session] : [] };
|
|
765
|
+
if (captureTail) {
|
|
766
|
+
const guard = snapshotGuard(filePath, snapshotSize);
|
|
767
|
+
result.tail = {
|
|
768
|
+
parsedBytes: snapshotSize,
|
|
769
|
+
parsedRecordIndex,
|
|
770
|
+
rawTokenSeen,
|
|
771
|
+
firstSessionMetaSeen,
|
|
772
|
+
turnContextModel,
|
|
773
|
+
prevTotal,
|
|
774
|
+
prevCumulativeTotal,
|
|
775
|
+
buckets,
|
|
776
|
+
sessionAccumulator,
|
|
777
|
+
guardHash: guard.hash,
|
|
778
|
+
endsWithNewline: guard.endsWithNewline,
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
return result;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function mergeFileResults(results) {
|
|
785
|
+
const entries = [];
|
|
786
|
+
const sessions = [];
|
|
787
|
+
for (const result of results) {
|
|
788
|
+
for (const bucket of result.buckets || []) {
|
|
789
|
+
entries.push({
|
|
790
|
+
source: bucket.source,
|
|
791
|
+
model: bucket.model,
|
|
792
|
+
project: bucket.project,
|
|
793
|
+
...(bucket.hostname ? { hostname: bucket.hostname } : {}),
|
|
794
|
+
timestamp: new Date(bucket.bucketStart),
|
|
795
|
+
inputTokens: bucket.inputTokens,
|
|
796
|
+
outputTokens: bucket.outputTokens,
|
|
797
|
+
cachedInputTokens: bucket.cachedInputTokens,
|
|
798
|
+
reasoningOutputTokens: bucket.reasoningOutputTokens,
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
sessions.push(...(result.sessions || []));
|
|
802
|
+
}
|
|
803
|
+
return { buckets: aggregateToBuckets(entries), sessions };
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
export async function parse() {
|
|
807
|
+
const codexHome = getCodexHome();
|
|
808
|
+
const dirs = sessionsDirs(codexHome);
|
|
809
|
+
if (!dirs.some(existsSync)) return { buckets: [], sessions: [] };
|
|
810
|
+
|
|
811
|
+
const startedAt = Date.now();
|
|
812
|
+
const budget = workBudgetMs();
|
|
813
|
+
const overBudget = () => Date.now() - startedAt >= budget;
|
|
814
|
+
const cacheStats = {
|
|
815
|
+
headerHits: 0,
|
|
816
|
+
indexHits: 0,
|
|
817
|
+
resultHits: 0,
|
|
818
|
+
tailHits: 0,
|
|
819
|
+
filesRead: 0,
|
|
820
|
+
audited: 0,
|
|
821
|
+
};
|
|
822
|
+
const files = [];
|
|
823
|
+
for (const filePath of dirs.flatMap(findJsonlFiles)) {
|
|
824
|
+
try {
|
|
825
|
+
const stat = statSync(filePath);
|
|
826
|
+
if (stat.size <= 0) continue;
|
|
827
|
+
const signature = fileSignature(stat);
|
|
828
|
+
const cache = loadCodexFileCache(codexHome, filePath, signature);
|
|
829
|
+
const priorCache = cache || loadCodexFileCache(codexHome, filePath);
|
|
830
|
+
const priorTail = cache ? null : loadCodexFileTail(codexHome, filePath);
|
|
831
|
+
const file = {
|
|
832
|
+
filePath,
|
|
833
|
+
snapshotSize: stat.size,
|
|
834
|
+
signature,
|
|
835
|
+
cache,
|
|
836
|
+
priorCache,
|
|
837
|
+
priorTail,
|
|
838
|
+
header: null,
|
|
839
|
+
appendTail: null,
|
|
840
|
+
};
|
|
841
|
+
if (!cache && priorCache) file.appendTail = tailStateFor(file);
|
|
842
|
+
files.push(file);
|
|
843
|
+
} catch {
|
|
844
|
+
// The file may move to archived_sessions between discovery and stat.
|
|
845
|
+
// Its archived copy will be picked up on the next sync.
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
if (files.length === 0) return { buckets: [], sessions: [] };
|
|
849
|
+
|
|
850
|
+
// A warm cache gets a tiny rolling correctness audit. Never mix this into a
|
|
851
|
+
// cold/resumed build: all files must already have complete results, and at
|
|
852
|
+
// most one bounded file is re-read per invocation.
|
|
853
|
+
const auditPaths = new Set();
|
|
854
|
+
if (files.every(file => file.cache?.header && file.cache?.result)) {
|
|
855
|
+
const cutoff = Date.now() - auditIntervalMs();
|
|
856
|
+
const candidate = files
|
|
857
|
+
.filter(file => file.snapshotSize <= auditMaxBytes())
|
|
858
|
+
.filter(file => (file.cache.lastAuditedAt || 0) <= cutoff)
|
|
859
|
+
.sort((a, b) => (a.cache.lastAuditedAt || 0) - (b.cache.lastAuditedAt || 0))[0];
|
|
860
|
+
if (candidate) auditPaths.add(candidate.filePath);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// Cheap discovery: cached headers require no rollout read. On a cold build,
|
|
864
|
+
// read only through the first session_meta so ordinary sessions can avoid
|
|
865
|
+
// the former all-files replay-index pass.
|
|
866
|
+
const candidatesById = new Map();
|
|
867
|
+
for (let i = 0; i < files.length; i++) {
|
|
868
|
+
const file = files[i];
|
|
869
|
+
const reusableHeader = file.cache?.header || (file.appendTail ? file.priorCache?.header : null);
|
|
870
|
+
if (reusableHeader && !auditPaths.has(file.filePath)) {
|
|
871
|
+
file.header = reusableHeader;
|
|
872
|
+
if (!file.cache) file.cache = { header: reusableHeader };
|
|
873
|
+
cacheStats.headerHits++;
|
|
874
|
+
} else {
|
|
875
|
+
try {
|
|
876
|
+
file.header = await readSessionHeader(file.filePath, file.snapshotSize);
|
|
877
|
+
cacheStats.filesRead++;
|
|
878
|
+
updateFileCache(codexHome, file, { header: file.header });
|
|
540
879
|
} catch {
|
|
541
880
|
continue;
|
|
542
881
|
}
|
|
543
882
|
}
|
|
883
|
+
if (file.header.sessionId) {
|
|
884
|
+
if (!candidatesById.has(file.header.sessionId)) candidatesById.set(file.header.sessionId, []);
|
|
885
|
+
candidatesById.get(file.header.sessionId).push(file);
|
|
886
|
+
}
|
|
887
|
+
if (overBudget() && i < files.length - 1) {
|
|
888
|
+
return {
|
|
889
|
+
buckets: [], sessions: [], skipped: true,
|
|
890
|
+
indexing: { phase: 'discovery', completed: i + 1, total: files.length },
|
|
891
|
+
cache: cacheStats,
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
}
|
|
544
895
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
896
|
+
const duplicateIds = new Set(
|
|
897
|
+
[...candidatesById].filter(([, candidates]) => candidates.length > 1).map(([id]) => id)
|
|
898
|
+
);
|
|
899
|
+
const referencedParentIds = new Set();
|
|
900
|
+
for (const file of files) {
|
|
901
|
+
const parentId = file.header?.forkedFromId
|
|
902
|
+
|| (file.header?.isSubagent ? file.header.parentThreadId : null);
|
|
903
|
+
if (parentId) referencedParentIds.add(parentId);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// Only replay participants, their parents, corrupt-header files, and
|
|
907
|
+
// duplicate physical copies need the full compact token index.
|
|
908
|
+
const fileMeta = new Map();
|
|
909
|
+
const needsIndex = new Set();
|
|
910
|
+
for (const file of files) {
|
|
911
|
+
const header = file.header;
|
|
912
|
+
const required = !header
|
|
913
|
+
|| !header.sessionId
|
|
914
|
+
|| header.isSubagent
|
|
915
|
+
|| header.forkedFromId != null
|
|
916
|
+
|| header.parentThreadId != null
|
|
917
|
+
|| referencedParentIds.has(header.sessionId)
|
|
918
|
+
|| duplicateIds.has(header.sessionId);
|
|
919
|
+
if (!required) {
|
|
920
|
+
fileMeta.set(file.filePath, { ...header, filePath: file.filePath });
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
needsIndex.add(file.filePath);
|
|
924
|
+
let meta = auditPaths.has(file.filePath) ? null : file.cache?.index;
|
|
925
|
+
if (meta) {
|
|
926
|
+
cacheStats.indexHits++;
|
|
927
|
+
} else {
|
|
928
|
+
try {
|
|
929
|
+
meta = await indexSessionFile(file.filePath, file.snapshotSize);
|
|
930
|
+
cacheStats.filesRead++;
|
|
931
|
+
updateFileCache(codexHome, file, { index: meta });
|
|
932
|
+
} catch {
|
|
933
|
+
continue;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
fileMeta.set(file.filePath, meta);
|
|
937
|
+
if (overBudget()) {
|
|
938
|
+
return {
|
|
939
|
+
buckets: [], sessions: [], skipped: true,
|
|
940
|
+
indexing: { phase: 'replay-index', completed: fileMeta.size, total: files.length },
|
|
941
|
+
cache: cacheStats,
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// Select the most complete physical copy exactly as before. Unique ordinary
|
|
947
|
+
// sessions have no full record count, but cannot compete with another copy.
|
|
948
|
+
const sessionById = new Map();
|
|
949
|
+
for (const file of files) {
|
|
950
|
+
const meta = fileMeta.get(file.filePath);
|
|
951
|
+
if (!meta?.sessionId) continue;
|
|
952
|
+
const existing = sessionById.get(meta.sessionId);
|
|
953
|
+
const count = meta.parsedRecordCount ?? 0;
|
|
954
|
+
const existingCount = existing?.parsedRecordCount ?? 0;
|
|
955
|
+
if (!existing || count > existingCount) sessionById.set(meta.sessionId, meta);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const results = [];
|
|
959
|
+
for (let i = 0; i < files.length; i++) {
|
|
960
|
+
const file = files[i];
|
|
961
|
+
const fm = fileMeta.get(file.filePath);
|
|
962
|
+
if (!fm) continue;
|
|
963
|
+
if (fm.sessionId && sessionById.get(fm.sessionId)?.filePath !== file.filePath) continue;
|
|
964
|
+
|
|
965
|
+
const boundary = needsIndex.has(file.filePath)
|
|
966
|
+
? replayBoundary(fm, sessionById)
|
|
967
|
+
: { rawTokenCount: 0, recordIndex: null };
|
|
968
|
+
const key = boundaryKey(boundary);
|
|
969
|
+
let result = !auditPaths.has(file.filePath) && file.cache?.result?.boundaryKey === key
|
|
970
|
+
? file.cache.result
|
|
971
|
+
: null;
|
|
972
|
+
if (result) {
|
|
973
|
+
cacheStats.resultHits++;
|
|
974
|
+
} else {
|
|
975
|
+
const previousTail = !needsIndex.has(file.filePath) && !auditPaths.has(file.filePath)
|
|
976
|
+
? file.appendTail
|
|
977
|
+
: null;
|
|
978
|
+
const parsed = await parseSessionFile(file.filePath, file.snapshotSize, fm, boundary, {
|
|
979
|
+
previousTail,
|
|
980
|
+
captureTail: !needsIndex.has(file.filePath),
|
|
981
|
+
});
|
|
982
|
+
cacheStats.filesRead++;
|
|
983
|
+
if (previousTail) cacheStats.tailHits++;
|
|
984
|
+
const { tail, ...summary } = parsed;
|
|
985
|
+
if (tail) {
|
|
986
|
+
try {
|
|
987
|
+
saveCodexFileTail(codexHome, file.filePath, file.signature, tail);
|
|
988
|
+
} catch {
|
|
989
|
+
// Same fail-open rule as the summary cache: tail acceleration is
|
|
990
|
+
// optional and a write failure must not fail the parser.
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
result = { boundaryKey: key, ...summary };
|
|
994
|
+
updateFileCache(codexHome, file, { result, lastAuditedAt: Date.now() });
|
|
995
|
+
file.appendTail = null;
|
|
996
|
+
file.priorTail = null;
|
|
997
|
+
if (auditPaths.has(file.filePath)) cacheStats.audited++;
|
|
998
|
+
}
|
|
999
|
+
results.push(result);
|
|
1000
|
+
|
|
1001
|
+
if (overBudget() && i < files.length - 1) {
|
|
1002
|
+
return {
|
|
1003
|
+
buckets: [], sessions: [], skipped: true,
|
|
1004
|
+
indexing: { phase: 'usage', completed: i + 1, total: files.length },
|
|
1005
|
+
cache: cacheStats,
|
|
1006
|
+
};
|
|
550
1007
|
}
|
|
551
1008
|
}
|
|
552
1009
|
|
|
553
|
-
return {
|
|
1010
|
+
return { ...mergeFileResults(results), cache: cacheStats };
|
|
554
1011
|
}
|