@geoqiao/pi-usage 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.
- package/LICENSE +22 -0
- package/README.md +222 -0
- package/bin/pi-usage.js +69 -0
- package/data/models.dev-LICENSE +21 -0
- package/data/prices.json +2678 -0
- package/extensions/usage-report.js +36 -0
- package/package.json +51 -0
- package/src/analytics.js +189 -0
- package/src/collect.js +34 -0
- package/src/network.js +25 -0
- package/src/report.js +43 -0
- package/vendor/vibe-usage/NOTICE.md +58 -0
- package/vendor/vibe-usage/src/cindy-roots.js +85 -0
- package/vendor/vibe-usage/src/claude-roots.js +165 -0
- package/vendor/vibe-usage/src/cline-roots.js +40 -0
- package/vendor/vibe-usage/src/codex-roots.js +46 -0
- package/vendor/vibe-usage/src/craft-roots.js +15 -0
- package/vendor/vibe-usage/src/extra-roots.js +312 -0
- package/vendor/vibe-usage/src/parsers/aggregate.js +196 -0
- package/vendor/vibe-usage/src/parsers/alma.js +94 -0
- package/vendor/vibe-usage/src/parsers/amp.js +156 -0
- package/vendor/vibe-usage/src/parsers/antigravity-db.js +359 -0
- package/vendor/vibe-usage/src/parsers/antigravity.js +530 -0
- package/vendor/vibe-usage/src/parsers/cindy-ledger.js +157 -0
- package/vendor/vibe-usage/src/parsers/claude-code.js +372 -0
- package/vendor/vibe-usage/src/parsers/cline.js +92 -0
- package/vendor/vibe-usage/src/parsers/codex-cache.js +138 -0
- package/vendor/vibe-usage/src/parsers/codex.js +1198 -0
- package/vendor/vibe-usage/src/parsers/contract.js +55 -0
- package/vendor/vibe-usage/src/parsers/copilot-cli.js +128 -0
- package/vendor/vibe-usage/src/parsers/craft-agent.js +21 -0
- package/vendor/vibe-usage/src/parsers/cursor.js +262 -0
- package/vendor/vibe-usage/src/parsers/dimagent.js +127 -0
- package/vendor/vibe-usage/src/parsers/droid.js +113 -0
- package/vendor/vibe-usage/src/parsers/dsh.js +563 -0
- package/vendor/vibe-usage/src/parsers/fs-utils.js +36 -0
- package/vendor/vibe-usage/src/parsers/gemini-cli.js +190 -0
- package/vendor/vibe-usage/src/parsers/grok.js +395 -0
- package/vendor/vibe-usage/src/parsers/hermes.js +123 -0
- package/vendor/vibe-usage/src/parsers/index.js +61 -0
- package/vendor/vibe-usage/src/parsers/kimi-code.js +467 -0
- package/vendor/vibe-usage/src/parsers/kiro.js +788 -0
- package/vendor/vibe-usage/src/parsers/mcode.js +182 -0
- package/vendor/vibe-usage/src/parsers/mimocode.js +88 -0
- package/vendor/vibe-usage/src/parsers/omp.js +10 -0
- package/vendor/vibe-usage/src/parsers/openclaw.js +142 -0
- package/vendor/vibe-usage/src/parsers/opencode.js +151 -0
- package/vendor/vibe-usage/src/parsers/pi-coding-agent.js +27 -0
- package/vendor/vibe-usage/src/parsers/pi-session-jsonl.js +166 -0
- package/vendor/vibe-usage/src/parsers/qwen-code.js +122 -0
- package/vendor/vibe-usage/src/parsers/roo-code.js +123 -0
- package/vendor/vibe-usage/src/parsers/sqlite.js +148 -0
- package/vendor/vibe-usage/src/parsers/trae-cli.js +171 -0
- package/vendor/vibe-usage/src/parsers/workbuddy.js +322 -0
- package/vendor/vibe-usage/src/parsers/zcode.js +115 -0
- package/vendor/vibe-usage/src/pi-roots.js +125 -0
- package/vendor/vibe-usage/src/tools.js +422 -0
- package/vendor/vibe-usage/src/workbuddy-roots.js +22 -0
- package/vendor/vibe-usage/upstream-files.json +48 -0
- package/web/report.css +10 -0
- package/web/report.html +81 -0
- package/web/report.js +310 -0
|
@@ -0,0 +1,1198 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
createReadStream,
|
|
4
|
+
existsSync,
|
|
5
|
+
openSync,
|
|
6
|
+
readSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
statSync,
|
|
9
|
+
} from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { createInterface } from 'node:readline';
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
import { aggregateToBuckets } from './aggregate.js';
|
|
14
|
+
import { mergeCindyHarnessUsage, readCindyHarnessUsage } from './cindy-ledger.js';
|
|
15
|
+
import {
|
|
16
|
+
codexSessionDirs,
|
|
17
|
+
resolveCodexHomes,
|
|
18
|
+
validateExtraCodexHome,
|
|
19
|
+
} from '../codex-roots.js';
|
|
20
|
+
import { discoverCodexHomes } from '../extra-roots.js';
|
|
21
|
+
import {
|
|
22
|
+
codexCacheEnabled,
|
|
23
|
+
fileSignature,
|
|
24
|
+
loadCodexFileCache,
|
|
25
|
+
loadCodexFileTail,
|
|
26
|
+
saveCodexFileCache,
|
|
27
|
+
saveCodexFileTail,
|
|
28
|
+
} from './codex-cache.js';
|
|
29
|
+
|
|
30
|
+
// Changing a model id changes its server-side bucket key. Keep pre-release
|
|
31
|
+
// history byte-for-byte stable so upgrading cannot re-upload the same tokens
|
|
32
|
+
// under tier-decorated keys and double-count them.
|
|
33
|
+
const CODEX_SERVICE_TIER_ATTRIBUTION_START_MS = Date.parse('2026-08-31T00:00:00.000Z');
|
|
34
|
+
|
|
35
|
+
function normalizeCodexServiceTier(value) {
|
|
36
|
+
if (typeof value !== 'string') return null;
|
|
37
|
+
const tier = value.trim().toLowerCase();
|
|
38
|
+
if (tier === 'fast' || tier === 'priority') return tier;
|
|
39
|
+
if (tier === 'flex' || tier === 'batch') return tier;
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function decorateCodexModel(model, serviceTier, timestampMs) {
|
|
44
|
+
const rawModel = model || 'unknown';
|
|
45
|
+
if (
|
|
46
|
+
rawModel === 'unknown'
|
|
47
|
+
|| !serviceTier
|
|
48
|
+
|| timestampMs < CODEX_SERVICE_TIER_ATTRIBUTION_START_MS
|
|
49
|
+
) {
|
|
50
|
+
return rawModel;
|
|
51
|
+
}
|
|
52
|
+
return `${rawModel}-${serviceTier}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Codex stores live sessions in $CODEX_HOME/sessions (default ~/.codex) and,
|
|
56
|
+
// once a session is "completed", moves its rollout file verbatim into
|
|
57
|
+
// $CODEX_HOME/archived_sessions. A session can be archived between two syncs,
|
|
58
|
+
// so scanning only the live dir loses that session's usage forever. We scan
|
|
59
|
+
// both, index them together so fork replay-skip works across directories, and
|
|
60
|
+
// select the most complete physical file when the same session briefly exists
|
|
61
|
+
// in both locations during an archive move.
|
|
62
|
+
/**
|
|
63
|
+
* Recursively find all .jsonl files under a directory.
|
|
64
|
+
* Codex CLI stores sessions as: ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
|
|
65
|
+
*/
|
|
66
|
+
function findJsonlFiles(dir, strict = false) {
|
|
67
|
+
const results = [];
|
|
68
|
+
if (!existsSync(dir)) return results;
|
|
69
|
+
try {
|
|
70
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
71
|
+
const fullPath = join(dir, entry.name);
|
|
72
|
+
if (entry.isDirectory()) {
|
|
73
|
+
for (const nested of findJsonlFiles(fullPath, strict)) results.push(nested);
|
|
74
|
+
} else if (entry.name.endsWith('.jsonl')) {
|
|
75
|
+
results.push(fullPath);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} catch (err) {
|
|
79
|
+
if (strict && err?.code !== 'ENOENT') throw err;
|
|
80
|
+
// Default roots are best-effort; configured roots must never look empty
|
|
81
|
+
// merely because a directory became unreadable between syncs.
|
|
82
|
+
}
|
|
83
|
+
return results;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readLines(filePath, snapshotSize, start = 0) {
|
|
87
|
+
return createInterface({
|
|
88
|
+
input: createReadStream(filePath, {
|
|
89
|
+
encoding: 'utf-8',
|
|
90
|
+
// Rollouts are append-only while Codex is working. Bound both parser
|
|
91
|
+
// passes to the size captured before pass 1 so they see the same prefix
|
|
92
|
+
// even when the live file grows between reads.
|
|
93
|
+
...(start > 0 ? { start } : {}),
|
|
94
|
+
...(snapshotSize == null ? {} : { end: snapshotSize - 1 }),
|
|
95
|
+
}),
|
|
96
|
+
crlfDelay: Infinity,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function extractProject(meta) {
|
|
101
|
+
if (meta.git?.repository_url) {
|
|
102
|
+
// e.g. https://github.com/org/repo.git → org/repo
|
|
103
|
+
const match = meta.git.repository_url.match(/([^/]+\/[^/]+?)(?:\.git)?$/);
|
|
104
|
+
if (match) return match[1];
|
|
105
|
+
}
|
|
106
|
+
if (meta.cwd) return meta.cwd.split('/').pop() || 'unknown';
|
|
107
|
+
return 'unknown';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A sub-agent rollout (spawned thread / guardian / collab agent). Depending
|
|
112
|
+
* on the Codex version the marker is `thread_source: "subagent"`, a
|
|
113
|
+
* `source: { subagent: {...} }` object, or just a `parent_thread_id` — check
|
|
114
|
+
* all three so no version's sub-agents slip through as normal sessions.
|
|
115
|
+
*/
|
|
116
|
+
function isSubagentMeta(meta) {
|
|
117
|
+
if (meta.thread_source === 'subagent') return true;
|
|
118
|
+
const src = meta.source;
|
|
119
|
+
if (src === 'subagent') return true;
|
|
120
|
+
if (src && typeof src === 'object' && 'subagent' in src) return true;
|
|
121
|
+
return meta.parent_thread_id != null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function extractParentThreadId(meta) {
|
|
125
|
+
return meta.parent_thread_id
|
|
126
|
+
|| meta.source?.subagent?.thread_spawn?.parent_thread_id
|
|
127
|
+
|| null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Read only far enough to find the canonical (first) session_meta. This cheap
|
|
132
|
+
* discovery pass lets ordinary sessions skip the old full-file index pass;
|
|
133
|
+
* only forks, sub-agents, and parents referenced by them need replay indexes.
|
|
134
|
+
*/
|
|
135
|
+
async function readSessionHeader(filePath, snapshotSize) {
|
|
136
|
+
for await (const line of readLines(filePath, snapshotSize)) {
|
|
137
|
+
if (!line.trim()) continue;
|
|
138
|
+
try {
|
|
139
|
+
const obj = JSON.parse(line);
|
|
140
|
+
if (obj.type !== 'session_meta' || !obj.payload) continue;
|
|
141
|
+
const meta = obj.payload;
|
|
142
|
+
return {
|
|
143
|
+
sessionId: meta.id || null,
|
|
144
|
+
forkedFromId: meta.forked_from_id || null,
|
|
145
|
+
parentThreadId: extractParentThreadId(meta),
|
|
146
|
+
sessionProject: extractProject(meta),
|
|
147
|
+
sessionStartedAtMs: timestampMs(meta.timestamp) ?? timestampMs(obj.timestamp),
|
|
148
|
+
isSubagent: isSubagentMeta(meta),
|
|
149
|
+
};
|
|
150
|
+
} catch {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
sessionId: null,
|
|
156
|
+
forkedFromId: null,
|
|
157
|
+
parentThreadId: null,
|
|
158
|
+
sessionProject: 'unknown',
|
|
159
|
+
sessionStartedAtMs: null,
|
|
160
|
+
isSubagent: false,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function cacheData(cache, changes = {}) {
|
|
165
|
+
return {
|
|
166
|
+
header: changes.header ?? cache?.header ?? null,
|
|
167
|
+
index: changes.index ?? cache?.index ?? null,
|
|
168
|
+
result: changes.result ?? cache?.result ?? null,
|
|
169
|
+
lastAuditedAt: changes.lastAuditedAt ?? cache?.lastAuditedAt ?? null,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const TAIL_GUARD_BYTES = 4096;
|
|
174
|
+
|
|
175
|
+
function snapshotGuard(filePath, size) {
|
|
176
|
+
if (size <= 0) return { hash: null, endsWithNewline: false };
|
|
177
|
+
const length = Math.min(size, TAIL_GUARD_BYTES);
|
|
178
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
179
|
+
const fd = openSync(filePath, 'r');
|
|
180
|
+
try {
|
|
181
|
+
const read = readSync(fd, buffer, 0, length, size - length);
|
|
182
|
+
const slice = buffer.subarray(0, read);
|
|
183
|
+
return {
|
|
184
|
+
hash: createHash('sha256').update(slice).digest('base64url').slice(0, 20),
|
|
185
|
+
endsWithNewline: slice.at(-1) === 0x0a,
|
|
186
|
+
};
|
|
187
|
+
} finally {
|
|
188
|
+
closeSync(fd);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function tailStateFor(file) {
|
|
193
|
+
const prior = file.priorTail;
|
|
194
|
+
const tail = prior?.tail;
|
|
195
|
+
if (!tail || !prior.signature) return null;
|
|
196
|
+
if (prior.signature.size <= 0 || prior.signature.size >= file.signature.size) return null;
|
|
197
|
+
if (prior.signature.dev !== file.signature.dev || prior.signature.ino !== file.signature.ino) return null;
|
|
198
|
+
if (prior.signature.mtimeMs > file.signature.mtimeMs) return null;
|
|
199
|
+
if (tail.parsedBytes !== prior.signature.size || !tail.endsWithNewline || !tail.guardHash) return null;
|
|
200
|
+
try {
|
|
201
|
+
const guard = snapshotGuard(file.filePath, prior.signature.size);
|
|
202
|
+
return guard.hash === tail.guardHash ? tail : null;
|
|
203
|
+
} catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function timestampMs(value) {
|
|
209
|
+
if (value == null || value === '') return null;
|
|
210
|
+
const n = new Date(value).getTime();
|
|
211
|
+
return Number.isFinite(n) ? n : null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function epochMs(value) {
|
|
215
|
+
if (typeof value === 'string' && value.trim() !== '') value = Number(value);
|
|
216
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
|
|
217
|
+
return value < 1e12 ? value * 1000 : value;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function isTaskStarted(payload) {
|
|
221
|
+
return payload?.type === 'task_started' || payload?.type === 'turn_started';
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function upperBound(sorted, target) {
|
|
225
|
+
let lo = 0;
|
|
226
|
+
let hi = sorted.length;
|
|
227
|
+
while (lo < hi) {
|
|
228
|
+
const mid = lo + ((hi - lo) >> 1);
|
|
229
|
+
if (sorted[mid] <= target) lo = mid + 1;
|
|
230
|
+
else hi = mid;
|
|
231
|
+
}
|
|
232
|
+
return lo;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function tokenFingerprint(payload) {
|
|
236
|
+
// Copied rollout items are re-serialized with a fresh outer timestamp, but
|
|
237
|
+
// their token_count payload is unchanged. A compact payload hash therefore
|
|
238
|
+
// identifies replayed records without retaining raw usage objects in memory.
|
|
239
|
+
return createHash('sha256')
|
|
240
|
+
.update(JSON.stringify(payload))
|
|
241
|
+
.digest('base64url')
|
|
242
|
+
.slice(0, 16);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Return the longest prefix of `child` that is also a suffix of `parent`.
|
|
247
|
+
* Codex can fork full history or the last N turns, but the copied block always
|
|
248
|
+
* reaches the source snapshot's end. Requiring the suffix prevents a child's
|
|
249
|
+
* coincidentally repeated payload from matching an unrelated interior turn.
|
|
250
|
+
* KMP keeps this linear even when many token payloads are identical.
|
|
251
|
+
*/
|
|
252
|
+
function longestReplayPrefix(child, parent) {
|
|
253
|
+
if (child.length === 0 || parent.length === 0) return 0;
|
|
254
|
+
|
|
255
|
+
const prefix = new Array(child.length).fill(0);
|
|
256
|
+
for (let i = 1, matched = 0; i < child.length; i++) {
|
|
257
|
+
while (matched > 0 && child[i] !== child[matched]) matched = prefix[matched - 1];
|
|
258
|
+
if (child[i] === child[matched]) matched++;
|
|
259
|
+
prefix[i] = matched;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
let matched = 0;
|
|
263
|
+
for (let i = 0; i < parent.length; i++) {
|
|
264
|
+
const fingerprint = parent[i];
|
|
265
|
+
while (matched > 0 && fingerprint !== child[matched]) matched = prefix[matched - 1];
|
|
266
|
+
if (fingerprint === child[matched]) matched++;
|
|
267
|
+
if (matched === child.length && i < parent.length - 1) matched = prefix[matched - 1];
|
|
268
|
+
}
|
|
269
|
+
return matched;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Return the longest prefix of `child` found contiguously anywhere in
|
|
274
|
+
* `parent`. A live sub-agent rollout can be observed while Codex is still
|
|
275
|
+
* copying the parent block, before that copy reaches the parent snapshot's
|
|
276
|
+
* end. In that state the exact records are inherited history even though the
|
|
277
|
+
* stricter completed-replay suffix match above deliberately rejects them.
|
|
278
|
+
*/
|
|
279
|
+
function longestPartialReplayPrefix(child, parent) {
|
|
280
|
+
if (child.length === 0 || parent.length === 0) return 0;
|
|
281
|
+
|
|
282
|
+
const prefix = new Array(child.length).fill(0);
|
|
283
|
+
for (let i = 1, matched = 0; i < child.length; i++) {
|
|
284
|
+
while (matched > 0 && child[i] !== child[matched]) matched = prefix[matched - 1];
|
|
285
|
+
if (child[i] === child[matched]) matched++;
|
|
286
|
+
prefix[i] = matched;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
let matched = 0;
|
|
290
|
+
let longest = 0;
|
|
291
|
+
for (const fingerprint of parent) {
|
|
292
|
+
while (matched > 0 && fingerprint !== child[matched]) matched = prefix[matched - 1];
|
|
293
|
+
if (fingerprint === child[matched]) matched++;
|
|
294
|
+
longest = Math.max(longest, matched);
|
|
295
|
+
if (matched === child.length) matched = prefix[matched - 1];
|
|
296
|
+
}
|
|
297
|
+
return longest;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// `task_started.started_at` is stored at one-second precision while the
|
|
301
|
+
// canonical session timestamp has milliseconds. Real Codex Desktop rollouts
|
|
302
|
+
// start the child task within a few seconds of creating the child session.
|
|
303
|
+
const OWN_TASK_START_WINDOW_MS = 5_000;
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Stream a rollout once and build a compact replay index. A fork/sub-agent
|
|
307
|
+
* file starts with its own session_meta and can then contain the source
|
|
308
|
+
* session's complete metadata and history. Only the first session_meta is
|
|
309
|
+
* canonical; later ones are replayed records and must never overwrite it.
|
|
310
|
+
*
|
|
311
|
+
* tokenTimes preserves raw token_count ordinals (including malformed usage
|
|
312
|
+
* records) on a monotonic timeline. tokenFingerprints identifies an exact
|
|
313
|
+
* copied sequence even when Codex forks only the last N turns instead of a
|
|
314
|
+
* full prefix. Together they bound matching to source records that existed at
|
|
315
|
+
* spawn without over-skipping child work when the parent later grows.
|
|
316
|
+
*/
|
|
317
|
+
async function indexSessionFile(filePath, snapshotSize) {
|
|
318
|
+
let sessionId = null;
|
|
319
|
+
let forkedFromId = null;
|
|
320
|
+
let parentThreadId = null;
|
|
321
|
+
let sessionProject = 'unknown';
|
|
322
|
+
let sessionStartedAtMs = null;
|
|
323
|
+
let isSubagent = false;
|
|
324
|
+
let sessionMetaCount = 0;
|
|
325
|
+
let parsedRecordCount = 0;
|
|
326
|
+
let rawTokenCount = 0;
|
|
327
|
+
let logicalTimestamp = Number.NEGATIVE_INFINITY;
|
|
328
|
+
const tokenTimes = [];
|
|
329
|
+
const tokenFingerprints = [];
|
|
330
|
+
let pendingTokenTimeIndexes = [];
|
|
331
|
+
const taskBoundaries = [];
|
|
332
|
+
let firstTaskBoundary = null;
|
|
333
|
+
let ownTaskBoundary = null;
|
|
334
|
+
|
|
335
|
+
for await (const line of readLines(filePath, snapshotSize)) {
|
|
336
|
+
if (!line.trim()) continue;
|
|
337
|
+
try {
|
|
338
|
+
const obj = JSON.parse(line);
|
|
339
|
+
parsedRecordCount++;
|
|
340
|
+
|
|
341
|
+
const recordTimestamp = timestampMs(obj.timestamp);
|
|
342
|
+
if (recordTimestamp != null) {
|
|
343
|
+
logicalTimestamp = Math.max(logicalTimestamp, recordTimestamp);
|
|
344
|
+
// An invalid token_count timestamp is placed at the next valid record
|
|
345
|
+
// time. If there is no next valid record it remains +Infinity, which
|
|
346
|
+
// deliberately biases a parent-at-spawn boundary toward under-skip.
|
|
347
|
+
for (const idx of pendingTokenTimeIndexes) tokenTimes[idx] = logicalTimestamp;
|
|
348
|
+
pendingTokenTimeIndexes = [];
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (obj.type === 'session_meta' && obj.payload) {
|
|
352
|
+
sessionMetaCount++;
|
|
353
|
+
if (sessionMetaCount === 1) {
|
|
354
|
+
const meta = obj.payload;
|
|
355
|
+
sessionId = meta.id || null;
|
|
356
|
+
forkedFromId = meta.forked_from_id || null;
|
|
357
|
+
parentThreadId = extractParentThreadId(meta);
|
|
358
|
+
isSubagent = isSubagentMeta(meta);
|
|
359
|
+
sessionProject = extractProject(meta);
|
|
360
|
+
sessionStartedAtMs = timestampMs(meta.timestamp) ?? recordTimestamp;
|
|
361
|
+
}
|
|
362
|
+
} else if (obj.type === 'event_msg' && obj.payload?.type === 'token_count') {
|
|
363
|
+
rawTokenCount++;
|
|
364
|
+
tokenFingerprints.push(tokenFingerprint(obj.payload));
|
|
365
|
+
if (recordTimestamp == null) {
|
|
366
|
+
tokenTimes.push(Number.POSITIVE_INFINITY);
|
|
367
|
+
pendingTokenTimeIndexes.push(tokenTimes.length - 1);
|
|
368
|
+
} else {
|
|
369
|
+
tokenTimes.push(logicalTimestamp);
|
|
370
|
+
}
|
|
371
|
+
} else if (obj.type === 'event_msg' && isTaskStarted(obj.payload)) {
|
|
372
|
+
const boundary = {
|
|
373
|
+
recordIndex: parsedRecordCount,
|
|
374
|
+
rawTokenCount,
|
|
375
|
+
startedAtMs: epochMs(obj.payload.started_at),
|
|
376
|
+
};
|
|
377
|
+
taskBoundaries.push(boundary);
|
|
378
|
+
firstTaskBoundary ??= boundary;
|
|
379
|
+
|
|
380
|
+
const startedAtMs = boundary.startedAtMs;
|
|
381
|
+
if (sessionStartedAtMs != null && startedAtMs != null
|
|
382
|
+
&& Math.abs(startedAtMs - sessionStartedAtMs) <= OWN_TASK_START_WINDOW_MS) {
|
|
383
|
+
// Keep the last match so a copied parent task that happened to start
|
|
384
|
+
// in the same second cannot win over the child's later own boundary.
|
|
385
|
+
ownTaskBoundary = boundary;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
} catch {
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
filePath,
|
|
395
|
+
sessionId,
|
|
396
|
+
forkedFromId,
|
|
397
|
+
parentThreadId,
|
|
398
|
+
sessionProject,
|
|
399
|
+
sessionStartedAtMs,
|
|
400
|
+
isSubagent,
|
|
401
|
+
sessionMetaCount,
|
|
402
|
+
parsedRecordCount,
|
|
403
|
+
rawTokenCount,
|
|
404
|
+
tokenTimes,
|
|
405
|
+
tokenFingerprints,
|
|
406
|
+
taskBoundaries,
|
|
407
|
+
firstTaskBoundary,
|
|
408
|
+
ownTaskBoundary,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function replayBoundary(meta, sessionById) {
|
|
413
|
+
const parentId = meta.forkedFromId || (meta.isSubagent ? meta.parentThreadId : null);
|
|
414
|
+
const parent = parentId ? sessionById.get(parentId) : null;
|
|
415
|
+
const parentAtSpawn = parent && meta.sessionStartedAtMs != null
|
|
416
|
+
? upperBound(parent.tokenTimes, meta.sessionStartedAtMs)
|
|
417
|
+
: null;
|
|
418
|
+
const parentSnapshot = parentAtSpawn == null
|
|
419
|
+
? []
|
|
420
|
+
: parent.tokenFingerprints.slice(0, parentAtSpawn);
|
|
421
|
+
const replayTokenCount = longestReplayPrefix(meta.tokenFingerprints, parentSnapshot);
|
|
422
|
+
const partialReplayTokenCount = meta.isSubagent
|
|
423
|
+
? longestPartialReplayPrefix(meta.tokenFingerprints, parentSnapshot)
|
|
424
|
+
: 0;
|
|
425
|
+
|
|
426
|
+
if (meta.isSubagent) {
|
|
427
|
+
// Direct evidence inside the child wins. Legacy single-meta rollouts did
|
|
428
|
+
// not replay task_started records, so their first task remains a safe
|
|
429
|
+
// fallback. Double-meta files must not use their copied parent's first
|
|
430
|
+
// task_started as the boundary.
|
|
431
|
+
// Exact token matching also handles LastNTurns forks. When it identifies
|
|
432
|
+
// the copied token suffix, the last task_started at that same raw ordinal
|
|
433
|
+
// is the child's own task boundary (copied history is written first).
|
|
434
|
+
const matchedTaskBoundaries = replayTokenCount > 0
|
|
435
|
+
? meta.taskBoundaries.filter(boundary => (
|
|
436
|
+
boundary.rawTokenCount === replayTokenCount
|
|
437
|
+
&& boundary.startedAtMs != null
|
|
438
|
+
&& meta.sessionStartedAtMs != null
|
|
439
|
+
&& boundary.startedAtMs >= Math.floor(meta.sessionStartedAtMs / 1000) * 1000
|
|
440
|
+
))
|
|
441
|
+
: [];
|
|
442
|
+
const matchedTaskBoundary = matchedTaskBoundaries.at(-1) || null;
|
|
443
|
+
const direct = matchedTaskBoundary
|
|
444
|
+
|| meta.ownTaskBoundary
|
|
445
|
+
|| (meta.sessionMetaCount === 1 && !meta.forkedFromId
|
|
446
|
+
? meta.firstTaskBoundary
|
|
447
|
+
: null);
|
|
448
|
+
if (direct) {
|
|
449
|
+
return {
|
|
450
|
+
rawTokenCount: Math.max(
|
|
451
|
+
replayTokenCount,
|
|
452
|
+
partialReplayTokenCount,
|
|
453
|
+
direct.rawTokenCount
|
|
454
|
+
),
|
|
455
|
+
recordIndex: direct.recordIndex,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// A recognized sub-agent can be synced while Codex is only partway
|
|
460
|
+
// through appending the copied parent block. The completed-replay matcher
|
|
461
|
+
// correctly rejects that interior slice, but counting it would create a
|
|
462
|
+
// temporary spike that disappears on the next sync. Exact payload overlap
|
|
463
|
+
// with the known parent is sufficient evidence to defer those leading
|
|
464
|
+
// records until the rollout reaches a stable suffix or task boundary.
|
|
465
|
+
return {
|
|
466
|
+
rawTokenCount: Math.max(replayTokenCount, partialReplayTokenCount),
|
|
467
|
+
recordIndex: null,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (meta.forkedFromId) {
|
|
472
|
+
return { rawTokenCount: replayTokenCount, recordIndex: null };
|
|
473
|
+
}
|
|
474
|
+
return { rawTokenCount: 0, recordIndex: null };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function boundaryKey(boundary) {
|
|
478
|
+
return `${boundary.rawTokenCount}:${boundary.recordIndex ?? ''}`;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function updateFileCache(file, changes) {
|
|
482
|
+
const data = cacheData(file.cache, changes);
|
|
483
|
+
try {
|
|
484
|
+
saveCodexFileCache(file.codexHome, file.filePath, file.signature, data);
|
|
485
|
+
} catch {
|
|
486
|
+
// A read-only home, full disk, or antivirus race must only disable the
|
|
487
|
+
// optimization for this run. Raw-log parsing remains the source of truth.
|
|
488
|
+
}
|
|
489
|
+
file.cache = { ...(file.cache || {}), ...data };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function workBudgetMs() {
|
|
493
|
+
const configured = Number(process.env.VIBE_USAGE_CODEX_WORK_BUDGET_MS);
|
|
494
|
+
if (Number.isFinite(configured) && configured > 0) return configured;
|
|
495
|
+
// The macOS app terminates its child after 120 seconds. Cache-building work
|
|
496
|
+
// in a non-interactive child therefore checkpoints before that wall so the
|
|
497
|
+
// next invocation resumes instead of starting from zero. Interactive users
|
|
498
|
+
// can let a cold build finish in one run (and can interrupt it safely).
|
|
499
|
+
if (codexCacheEnabled() && !process.stdout.isTTY) return 105_000;
|
|
500
|
+
return Number.POSITIVE_INFINITY;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function auditIntervalMs() {
|
|
504
|
+
const configured = Number(process.env.VIBE_USAGE_CODEX_AUDIT_INTERVAL_MS);
|
|
505
|
+
if (Number.isFinite(configured) && configured >= 0) return configured;
|
|
506
|
+
return 30 * 24 * 60 * 60 * 1000;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function auditMaxBytes() {
|
|
510
|
+
const configured = Number(process.env.VIBE_USAGE_CODEX_AUDIT_MAX_BYTES);
|
|
511
|
+
if (Number.isFinite(configured) && configured > 0) return configured;
|
|
512
|
+
// Keep the background audit bounded below the app's wall timeout. Larger
|
|
513
|
+
// active files are still invalidated immediately by their stat signature,
|
|
514
|
+
// and every cache generation is rebuilt after parser-algorithm changes.
|
|
515
|
+
return 64 * 1024 * 1024;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function applySessionEvent(acc, event) {
|
|
519
|
+
const timestampMsValue = event.timestamp.getTime();
|
|
520
|
+
if (acc.lastTimestampMs != null && timestampMsValue < acc.lastTimestampMs) return false;
|
|
521
|
+
acc.firstTimestampMs ??= timestampMsValue;
|
|
522
|
+
acc.lastTimestampMs = timestampMsValue;
|
|
523
|
+
acc.messageCount++;
|
|
524
|
+
|
|
525
|
+
if (event.role === 'user') {
|
|
526
|
+
if (acc.turnStartMs != null && acc.turnEndMs != null && acc.turnEndMs > acc.turnStartMs) {
|
|
527
|
+
acc.completedActiveSeconds += Math.round((acc.turnEndMs - acc.turnStartMs) / 1000);
|
|
528
|
+
}
|
|
529
|
+
acc.turnStartMs = null;
|
|
530
|
+
acc.turnEndMs = null;
|
|
531
|
+
acc.waitingForFirstResponse = true;
|
|
532
|
+
acc.userMessageCount++;
|
|
533
|
+
acc.userPromptHours[event.timestamp.getUTCHours()]++;
|
|
534
|
+
} else if (acc.waitingForFirstResponse) {
|
|
535
|
+
acc.turnStartMs = timestampMsValue;
|
|
536
|
+
acc.turnEndMs = timestampMsValue;
|
|
537
|
+
acc.waitingForFirstResponse = false;
|
|
538
|
+
} else if (acc.turnStartMs != null) {
|
|
539
|
+
acc.turnEndMs = timestampMsValue;
|
|
540
|
+
}
|
|
541
|
+
return true;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function buildSessionAccumulator(events, previous = null) {
|
|
545
|
+
const sorted = [...events].sort((a, b) => a.timestamp - b.timestamp);
|
|
546
|
+
const first = sorted[0];
|
|
547
|
+
const acc = previous
|
|
548
|
+
? {
|
|
549
|
+
...previous,
|
|
550
|
+
userPromptHours: [...previous.userPromptHours],
|
|
551
|
+
}
|
|
552
|
+
: {
|
|
553
|
+
sessionId: first?.sessionId || null,
|
|
554
|
+
source: first?.source || null,
|
|
555
|
+
project: first?.project || 'unknown',
|
|
556
|
+
firstTimestampMs: null,
|
|
557
|
+
lastTimestampMs: null,
|
|
558
|
+
completedActiveSeconds: 0,
|
|
559
|
+
turnStartMs: null,
|
|
560
|
+
turnEndMs: null,
|
|
561
|
+
waitingForFirstResponse: false,
|
|
562
|
+
messageCount: 0,
|
|
563
|
+
userMessageCount: 0,
|
|
564
|
+
userPromptHours: new Array(24).fill(0),
|
|
565
|
+
};
|
|
566
|
+
for (const event of sorted) {
|
|
567
|
+
if (!applySessionEvent(acc, event)) return null;
|
|
568
|
+
}
|
|
569
|
+
return acc.sessionId ? acc : null;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function sessionFromAccumulator(acc) {
|
|
573
|
+
if (!acc?.sessionId || acc.firstTimestampMs == null || acc.lastTimestampMs == null) return null;
|
|
574
|
+
let activeSeconds = acc.completedActiveSeconds;
|
|
575
|
+
if (acc.turnStartMs != null && acc.turnEndMs != null && acc.turnEndMs > acc.turnStartMs) {
|
|
576
|
+
activeSeconds += Math.round((acc.turnEndMs - acc.turnStartMs) / 1000);
|
|
577
|
+
}
|
|
578
|
+
return {
|
|
579
|
+
source: acc.source,
|
|
580
|
+
project: acc.project || 'unknown',
|
|
581
|
+
sessionHash: createHash('sha256').update(acc.sessionId).digest('hex').slice(0, 16),
|
|
582
|
+
firstMessageAt: new Date(acc.firstTimestampMs).toISOString(),
|
|
583
|
+
lastMessageAt: new Date(acc.lastTimestampMs).toISOString(),
|
|
584
|
+
durationSeconds: Math.round((acc.lastTimestampMs - acc.firstTimestampMs) / 1000),
|
|
585
|
+
activeSeconds,
|
|
586
|
+
messageCount: acc.messageCount,
|
|
587
|
+
userMessageCount: acc.userMessageCount,
|
|
588
|
+
userPromptHours: acc.userPromptHours,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Rollout order is a protocol boundary, not a nearest-tool heuristic:
|
|
593
|
+
// OutputItemDone items -> response completion -> drained tools -> token_count.
|
|
594
|
+
// A turn contains MANY requests. Only the interval since a known boundary is
|
|
595
|
+
// eligible; cumulative-only usage, broken intervals and mixed IDs stay unknown.
|
|
596
|
+
function responseEvidence(open = false, turnId = null) {
|
|
597
|
+
return { open, turnId, tool: false, message: false, invalid: false, responseId: null, completed: null };
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const CODEX_TOOL_ITEMS = new Set(['function_call', 'custom_tool_call', 'local_shell_call', 'web_search_call', 'image_generation_call', 'tool_search_call']);
|
|
601
|
+
const CODEX_USAGE_FIELDS = ['input_tokens', 'cached_input_tokens', 'output_tokens', 'reasoning_output_tokens', 'total_tokens'];
|
|
602
|
+
function sameResponseUsage(a, b) {
|
|
603
|
+
return a && b && CODEX_USAGE_FIELDS.every(key => (a[key] ?? 0) === (b[key] ?? 0));
|
|
604
|
+
}
|
|
605
|
+
function evidenceType(evidence) {
|
|
606
|
+
if (!evidence.open || evidence.invalid) return 'other';
|
|
607
|
+
return evidence.tool ? 'tool' : evidence.message ? 'non_tool' : 'other';
|
|
608
|
+
}
|
|
609
|
+
function observeResponse(evidence, obj, sessionId) {
|
|
610
|
+
const p = obj.payload;
|
|
611
|
+
if (obj.type === 'session_meta' || obj.type === 'compacted'
|
|
612
|
+
|| (obj.type === 'event_msg' && ['task_complete', 'turn_complete', 'turn_aborted', 'context_compacted', 'error'].includes(p?.type))) {
|
|
613
|
+
return responseEvidence();
|
|
614
|
+
}
|
|
615
|
+
if (obj.type === 'turn_context' || (obj.type === 'event_msg' && isTaskStarted(p))) {
|
|
616
|
+
return { ...responseEvidence(true, p?.turn_id ?? null), invalid: evidence.tool || evidence.message || !!evidence.completed };
|
|
617
|
+
}
|
|
618
|
+
if (obj.type === 'event_msg' && p?.type === 'user_message') {
|
|
619
|
+
return { ...responseEvidence(true, evidence.turnId), invalid: evidence.tool || evidence.message || !!evidence.completed };
|
|
620
|
+
}
|
|
621
|
+
if (obj.type === 'token_usage_record') {
|
|
622
|
+
if (p?.thread_id && sessionId && p.thread_id !== sessionId) evidence.invalid = true;
|
|
623
|
+
if (p?.turn_id && evidence.turnId && p.turn_id !== evidence.turnId) evidence.invalid = true;
|
|
624
|
+
if (evidence.responseId && p?.response_id !== evidence.responseId) evidence.invalid = true;
|
|
625
|
+
if (evidence.completed) {
|
|
626
|
+
if (p?.response_id !== evidence.completed.id || !sameResponseUsage(p?.usage, evidence.completed.usage)) evidence.invalid = true;
|
|
627
|
+
} else {
|
|
628
|
+
evidence.completed = { id: p?.response_id, usage: p?.usage, requestType: evidenceType(evidence) };
|
|
629
|
+
}
|
|
630
|
+
return evidence;
|
|
631
|
+
}
|
|
632
|
+
// item_completed/tool-result events describe UI/execution, not another model
|
|
633
|
+
// response. In particular, parallel tool results must not multiply usage.
|
|
634
|
+
if (obj.type !== 'response_item') return evidence;
|
|
635
|
+
if (!p || typeof p !== 'object') { evidence.invalid = true; return evidence; }
|
|
636
|
+
const turnId = p.internal_chat_message_metadata_passthrough?.turn_id;
|
|
637
|
+
if (turnId && evidence.turnId && turnId !== evidence.turnId) evidence.invalid = true;
|
|
638
|
+
evidence.turnId ??= turnId ?? null;
|
|
639
|
+
if (p.response_id) {
|
|
640
|
+
if (evidence.responseId && evidence.responseId !== p.response_id) evidence.invalid = true;
|
|
641
|
+
evidence.responseId = p.response_id;
|
|
642
|
+
}
|
|
643
|
+
if (p.type?.endsWith('_output')) return evidence;
|
|
644
|
+
if (p.type === 'message' && p.role !== 'assistant') {
|
|
645
|
+
if (evidence.tool || evidence.message) evidence.invalid = true;
|
|
646
|
+
return evidence;
|
|
647
|
+
}
|
|
648
|
+
if (evidence.completed) evidence.invalid = true;
|
|
649
|
+
if (CODEX_TOOL_ITEMS.has(p.type)) evidence.tool = true;
|
|
650
|
+
else if (p.type === 'message' && Array.isArray(p.content)) evidence.message = true;
|
|
651
|
+
else if (p.type !== 'reasoning') evidence.invalid = true;
|
|
652
|
+
return evidence;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function mergeBucketLists(lists) {
|
|
656
|
+
const entries = [];
|
|
657
|
+
for (const buckets of lists) {
|
|
658
|
+
for (const bucket of buckets || []) {
|
|
659
|
+
entries.push({
|
|
660
|
+
requestType: bucket.requestType,
|
|
661
|
+
source: bucket.source,
|
|
662
|
+
model: bucket.model,
|
|
663
|
+
project: bucket.project,
|
|
664
|
+
...(bucket.hostname ? { hostname: bucket.hostname } : {}),
|
|
665
|
+
timestamp: new Date(bucket.bucketStart),
|
|
666
|
+
inputTokens: bucket.inputTokens,
|
|
667
|
+
outputTokens: bucket.outputTokens,
|
|
668
|
+
cachedInputTokens: bucket.cachedInputTokens,
|
|
669
|
+
reasoningOutputTokens: bucket.reasoningOutputTokens,
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
return aggregateToBuckets(entries);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
async function parseSessionFile(filePath, snapshotSize, fm, boundary, {
|
|
677
|
+
previousTail = null,
|
|
678
|
+
captureTail = false,
|
|
679
|
+
} = {}) {
|
|
680
|
+
const entries = [];
|
|
681
|
+
const sessionEvents = [];
|
|
682
|
+
let rawTokenSeen = previousTail?.rawTokenSeen || 0;
|
|
683
|
+
let parsedRecordIndex = previousTail?.parsedRecordIndex || 0;
|
|
684
|
+
let firstSessionMetaSeen = previousTail?.firstSessionMetaSeen || false;
|
|
685
|
+
|
|
686
|
+
const sessionProject = fm.sessionProject;
|
|
687
|
+
// Group timing events by the real Codex session id, not the file path: the
|
|
688
|
+
// same session can briefly exist in both sessions/ and archived_sessions/
|
|
689
|
+
// (mid-archive, or a re-synced archive). Path-keyed grouping would emit it
|
|
690
|
+
// as two different sessionHashes and double-count its session stats. Fall
|
|
691
|
+
// back to the path only when the id is unknown (corrupt/missing meta).
|
|
692
|
+
const sessionKey = fm.sessionId || filePath;
|
|
693
|
+
|
|
694
|
+
let turnContextModel = previousTail?.turnContextModel || 'unknown';
|
|
695
|
+
let serviceTier = previousTail?.serviceTier || null;
|
|
696
|
+
let prevTotal = previousTail?.prevTotal || null;
|
|
697
|
+
let prevCumulativeTotal = previousTail?.prevCumulativeTotal ?? null;
|
|
698
|
+
let evidence = previousTail?.responseEvidence || responseEvidence();
|
|
699
|
+
const start = previousTail?.parsedBytes || 0;
|
|
700
|
+
for await (const line of readLines(filePath, snapshotSize, start)) {
|
|
701
|
+
if (!line.trim()) continue;
|
|
702
|
+
try {
|
|
703
|
+
const obj = JSON.parse(line);
|
|
704
|
+
parsedRecordIndex++;
|
|
705
|
+
|
|
706
|
+
// A direct child task boundary covers every copied record, including
|
|
707
|
+
// timing/meta events. The raw-token ordinal covers full-history and
|
|
708
|
+
// last-N-turn forks whose exact payload sequence was matched in pass 1.
|
|
709
|
+
const beforeOwnTask = boundary.recordIndex != null
|
|
710
|
+
&& parsedRecordIndex < boundary.recordIndex;
|
|
711
|
+
const inReplayBlock = beforeOwnTask || rawTokenSeen < boundary.rawTokenCount;
|
|
712
|
+
|
|
713
|
+
const isSessionMeta = obj.type === 'session_meta';
|
|
714
|
+
const isCanonicalSessionMeta = isSessionMeta && !firstSessionMetaSeen;
|
|
715
|
+
const isOwnSessionMeta = isSessionMeta
|
|
716
|
+
&& obj.payload?.id != null
|
|
717
|
+
&& obj.payload.id === fm.sessionId;
|
|
718
|
+
if (isSessionMeta) firstSessionMetaSeen = true;
|
|
719
|
+
|
|
720
|
+
if (obj.timestamp) {
|
|
721
|
+
const evTs = new Date(obj.timestamp);
|
|
722
|
+
if (!isNaN(evTs.getTime())) {
|
|
723
|
+
// Repeated same-id metadata can be appended on resume/config
|
|
724
|
+
// updates and belongs to this logical session. A different-id meta
|
|
725
|
+
// is copied parent history and must not inflate timing stats.
|
|
726
|
+
const keepSessionMeta = isCanonicalSessionMeta
|
|
727
|
+
|| (isOwnSessionMeta && !inReplayBlock);
|
|
728
|
+
if (keepSessionMeta || (!isSessionMeta && !inReplayBlock)) {
|
|
729
|
+
const isUserTurn = obj.type === 'turn_context' || obj.type === 'session_meta';
|
|
730
|
+
sessionEvents.push({
|
|
731
|
+
sessionId: sessionKey,
|
|
732
|
+
source: 'codex',
|
|
733
|
+
project: sessionProject,
|
|
734
|
+
timestamp: evTs,
|
|
735
|
+
role: isUserTurn ? 'user' : 'assistant',
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
evidence = inReplayBlock ? responseEvidence() : observeResponse(evidence, obj, fm.sessionId);
|
|
742
|
+
|
|
743
|
+
if (obj.type === 'turn_context') {
|
|
744
|
+
if (obj.payload?.model) turnContextModel = obj.payload.model;
|
|
745
|
+
if (Object.hasOwn(obj.payload || {}, 'service_tier')) {
|
|
746
|
+
serviceTier = normalizeCodexServiceTier(obj.payload.service_tier);
|
|
747
|
+
}
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
if (obj.type !== 'event_msg') continue;
|
|
752
|
+
|
|
753
|
+
const payload = obj.payload;
|
|
754
|
+
if (!payload) continue;
|
|
755
|
+
|
|
756
|
+
if (payload.type === 'thread_settings_applied') {
|
|
757
|
+
const settings = payload.thread_settings;
|
|
758
|
+
if (settings?.model) turnContextModel = settings.model;
|
|
759
|
+
if (Object.hasOwn(settings || {}, 'service_tier')) {
|
|
760
|
+
serviceTier = normalizeCodexServiceTier(settings.service_tier);
|
|
761
|
+
}
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
if (payload.type !== 'token_count') continue;
|
|
766
|
+
|
|
767
|
+
// Raw ordinals advance before validating usage/timestamp so pass 1 and
|
|
768
|
+
// pass 2 cannot drift on a malformed copied token_count record.
|
|
769
|
+
const isReplayedHistory = inReplayBlock;
|
|
770
|
+
rawTokenSeen++;
|
|
771
|
+
|
|
772
|
+
const info = payload.info;
|
|
773
|
+
if (!info) continue;
|
|
774
|
+
|
|
775
|
+
// Codex sometimes writes the same token_count twice back-to-back:
|
|
776
|
+
// identical last_token_usage with an unchanged cumulative total. A
|
|
777
|
+
// real API call always advances the cumulative counter (its input
|
|
778
|
+
// tokens alone are non-zero), so an unchanged positive total marks a
|
|
779
|
+
// duplicate emission — or a zero-usage bookkeeping event such as
|
|
780
|
+
// compaction — and must count as zero, not a second copy of
|
|
781
|
+
// last_token_usage. Guarded to positive totals so builds that leave
|
|
782
|
+
// total_token_usage all-zero can't suppress real usage.
|
|
783
|
+
const cumulativeTotal = info.total_token_usage?.total_tokens;
|
|
784
|
+
const previousCumulativeTotal = prevCumulativeTotal;
|
|
785
|
+
const isDuplicateEmission = typeof cumulativeTotal === 'number'
|
|
786
|
+
&& cumulativeTotal > 0
|
|
787
|
+
&& cumulativeTotal === prevCumulativeTotal;
|
|
788
|
+
if (typeof cumulativeTotal === 'number') prevCumulativeTotal = cumulativeTotal;
|
|
789
|
+
|
|
790
|
+
// Prefer incremental per-request usage; compute delta from cumulative
|
|
791
|
+
// totals as fallback. Always advance the cumulative baseline, even
|
|
792
|
+
// when last_token_usage exists or the record belongs to a replay.
|
|
793
|
+
const curr = info.total_token_usage;
|
|
794
|
+
let usage = info.last_token_usage;
|
|
795
|
+
if (!usage && curr) {
|
|
796
|
+
if (prevTotal) {
|
|
797
|
+
const delta = {
|
|
798
|
+
input_tokens: (curr.input_tokens || 0) - (prevTotal.input_tokens || 0),
|
|
799
|
+
output_tokens: (curr.output_tokens || 0) - (prevTotal.output_tokens || 0),
|
|
800
|
+
cached_input_tokens: (curr.cached_input_tokens || 0) - (prevTotal.cached_input_tokens || 0),
|
|
801
|
+
reasoning_output_tokens: (curr.reasoning_output_tokens || 0) - (prevTotal.reasoning_output_tokens || 0),
|
|
802
|
+
};
|
|
803
|
+
// Cumulative counters can reset after compaction or a new usage
|
|
804
|
+
// window. Treat the first post-reset total as a fresh baseline;
|
|
805
|
+
// allowing a negative delta would cancel legitimate bucket usage.
|
|
806
|
+
usage = Object.values(delta).some(value => value < 0) ? curr : delta;
|
|
807
|
+
} else {
|
|
808
|
+
// First cumulative entry — use as-is (it's the first event's total)
|
|
809
|
+
usage = curr;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
// total_token_usage is session-wide, not per model. A global baseline
|
|
813
|
+
// avoids counting the full cumulative total again after a model switch.
|
|
814
|
+
if (curr) prevTotal = { ...curr };
|
|
815
|
+
if (isDuplicateEmission) continue;
|
|
816
|
+
const completedRequest = evidence.completed || (cumulativeTotal > 0
|
|
817
|
+
&& cumulativeTotal - (previousCumulativeTotal ?? 0) === info.last_token_usage?.total_tokens);
|
|
818
|
+
const requestType = info.last_token_usage && completedRequest && !evidence.invalid
|
|
819
|
+
? evidence.completed
|
|
820
|
+
? sameResponseUsage(evidence.completed.usage, usage) ? evidence.completed.requestType : 'other'
|
|
821
|
+
: evidenceType(evidence)
|
|
822
|
+
: 'other';
|
|
823
|
+
evidence = responseEvidence(!isReplayedHistory, evidence.turnId);
|
|
824
|
+
if (!usage || isReplayedHistory) continue;
|
|
825
|
+
|
|
826
|
+
const timestamp = obj.timestamp ? new Date(obj.timestamp) : null;
|
|
827
|
+
if (!timestamp || isNaN(timestamp.getTime())) continue;
|
|
828
|
+
|
|
829
|
+
const rawModel = info.model || payload.model || turnContextModel || 'unknown';
|
|
830
|
+
const model = decorateCodexModel(rawModel, serviceTier, timestamp.getTime());
|
|
831
|
+
|
|
832
|
+
// OpenAI API: input_tokens INCLUDES cached, output_tokens INCLUDES reasoning.
|
|
833
|
+
// Normalize to Anthropic-style semantics where each field is non-overlapping.
|
|
834
|
+
const cachedInput = usage.cached_input_tokens || usage.cache_read_input_tokens || 0;
|
|
835
|
+
const reasoningOutput = usage.reasoning_output_tokens || 0;
|
|
836
|
+
entries.push({
|
|
837
|
+
source: 'codex',
|
|
838
|
+
requestType,
|
|
839
|
+
model,
|
|
840
|
+
project: sessionProject,
|
|
841
|
+
timestamp,
|
|
842
|
+
inputTokens: (usage.input_tokens || 0) - cachedInput,
|
|
843
|
+
outputTokens: (usage.output_tokens || 0) - reasoningOutput,
|
|
844
|
+
cachedInputTokens: cachedInput,
|
|
845
|
+
reasoningOutputTokens: reasoningOutput,
|
|
846
|
+
});
|
|
847
|
+
} catch {
|
|
848
|
+
evidence.invalid = true;
|
|
849
|
+
continue;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Indexed files must match both passes exactly. Ordinary sessions take the
|
|
854
|
+
// single-pass fast path and have no expected counts; their byte-bounded
|
|
855
|
+
// snapshot is still stable, and any append invalidates the stat signature on
|
|
856
|
+
// the next sync.
|
|
857
|
+
if (fm.parsedRecordCount != null && fm.rawTokenCount != null) {
|
|
858
|
+
if (parsedRecordIndex !== fm.parsedRecordCount || rawTokenSeen !== fm.rawTokenCount) {
|
|
859
|
+
throw new Error('Codex rollout changed while syncing; retry on the next sync');
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const buckets = mergeBucketLists([
|
|
864
|
+
previousTail?.buckets || [],
|
|
865
|
+
aggregateToBuckets(entries),
|
|
866
|
+
]);
|
|
867
|
+
const sessionAccumulator = buildSessionAccumulator(
|
|
868
|
+
sessionEvents,
|
|
869
|
+
previousTail?.sessionAccumulator || null
|
|
870
|
+
);
|
|
871
|
+
// Appended records should be chronological. If an app version inserts an
|
|
872
|
+
// older event into the tail, the compact accumulator cannot reproduce the
|
|
873
|
+
// global sort exactly, so discard the optimization and rebuild this file.
|
|
874
|
+
if (previousTail && sessionEvents.length > 0 && !sessionAccumulator) {
|
|
875
|
+
return parseSessionFile(filePath, snapshotSize, fm, boundary, { captureTail });
|
|
876
|
+
}
|
|
877
|
+
const session = sessionFromAccumulator(sessionAccumulator);
|
|
878
|
+
const result = { buckets, sessions: session ? [session] : [] };
|
|
879
|
+
if (captureTail) {
|
|
880
|
+
const guard = snapshotGuard(filePath, snapshotSize);
|
|
881
|
+
result.tail = {
|
|
882
|
+
parsedBytes: snapshotSize,
|
|
883
|
+
parsedRecordIndex,
|
|
884
|
+
rawTokenSeen,
|
|
885
|
+
firstSessionMetaSeen,
|
|
886
|
+
turnContextModel,
|
|
887
|
+
serviceTier,
|
|
888
|
+
prevTotal,
|
|
889
|
+
prevCumulativeTotal,
|
|
890
|
+
responseEvidence: evidence,
|
|
891
|
+
buckets,
|
|
892
|
+
sessionAccumulator,
|
|
893
|
+
guardHash: guard.hash,
|
|
894
|
+
endsWithNewline: guard.endsWithNewline,
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
return result;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function mergeFileResults(results) {
|
|
901
|
+
const entries = [];
|
|
902
|
+
const sessions = [];
|
|
903
|
+
for (const result of results) {
|
|
904
|
+
for (const bucket of result.buckets || []) {
|
|
905
|
+
entries.push({
|
|
906
|
+
requestType: bucket.requestType,
|
|
907
|
+
source: bucket.source,
|
|
908
|
+
model: bucket.model,
|
|
909
|
+
project: bucket.project,
|
|
910
|
+
...(bucket.hostname ? { hostname: bucket.hostname } : {}),
|
|
911
|
+
timestamp: new Date(bucket.bucketStart),
|
|
912
|
+
inputTokens: bucket.inputTokens,
|
|
913
|
+
outputTokens: bucket.outputTokens,
|
|
914
|
+
cachedInputTokens: bucket.cachedInputTokens,
|
|
915
|
+
reasoningOutputTokens: bucket.reasoningOutputTokens,
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
for (const session of result.sessions || []) sessions.push(session);
|
|
919
|
+
}
|
|
920
|
+
return { buckets: aggregateToBuckets(entries), sessions };
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
async function parseNativeCodex({ codexExtraHome, extraRoots = [] } = {}) {
|
|
924
|
+
let extraCodexHomePath = null;
|
|
925
|
+
if (codexExtraHome?.trim()) {
|
|
926
|
+
const validation = validateExtraCodexHome(codexExtraHome);
|
|
927
|
+
if (!validation.ok) {
|
|
928
|
+
return {
|
|
929
|
+
buckets: [],
|
|
930
|
+
sessions: [],
|
|
931
|
+
skipped: true,
|
|
932
|
+
warnings: [`codex: 额外 Codex Home 不可用,已跳过本次 Codex 同步: ${validation.path}`],
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
extraCodexHomePath = validation.path;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const configuredHomes = [];
|
|
939
|
+
for (const root of extraRoots) {
|
|
940
|
+
const discovered = discoverCodexHomes(root);
|
|
941
|
+
if (!discovered.readable || discovered.homes.length === 0) {
|
|
942
|
+
return {
|
|
943
|
+
buckets: [],
|
|
944
|
+
sessions: [],
|
|
945
|
+
skipped: true,
|
|
946
|
+
warnings: [`codex: 额外根目录不可用,已跳过本次 Codex 同步: ${discovered.root}`],
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
configuredHomes.push(...discovered.homes);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
const strictHomes = new Set(configuredHomes);
|
|
953
|
+
if (extraCodexHomePath) strictHomes.add(extraCodexHomePath);
|
|
954
|
+
const codexHomes = [...new Set([
|
|
955
|
+
...resolveCodexHomes(codexExtraHome),
|
|
956
|
+
...configuredHomes,
|
|
957
|
+
])];
|
|
958
|
+
const dirs = codexHomes.flatMap(codexHome => (
|
|
959
|
+
codexSessionDirs(codexHome).map(dir => ({ codexHome, dir, strict: strictHomes.has(codexHome) }))
|
|
960
|
+
));
|
|
961
|
+
if (!dirs.some(({ dir }) => existsSync(dir))) return { buckets: [], sessions: [] };
|
|
962
|
+
|
|
963
|
+
const startedAt = Date.now();
|
|
964
|
+
const budget = workBudgetMs();
|
|
965
|
+
const overBudget = () => Date.now() - startedAt >= budget;
|
|
966
|
+
const cacheStats = {
|
|
967
|
+
headerHits: 0,
|
|
968
|
+
indexHits: 0,
|
|
969
|
+
resultHits: 0,
|
|
970
|
+
tailHits: 0,
|
|
971
|
+
filesRead: 0,
|
|
972
|
+
audited: 0,
|
|
973
|
+
};
|
|
974
|
+
const files = [];
|
|
975
|
+
for (const { codexHome, dir, strict } of dirs) {
|
|
976
|
+
let filePaths;
|
|
977
|
+
try {
|
|
978
|
+
filePaths = findJsonlFiles(dir, strict);
|
|
979
|
+
} catch {
|
|
980
|
+
return {
|
|
981
|
+
buckets: [], sessions: [], skipped: true,
|
|
982
|
+
warnings: [`codex: 额外根目录读取失败,已保留上次同步数据: ${codexHome}`],
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
for (const filePath of filePaths) {
|
|
986
|
+
try {
|
|
987
|
+
const stat = statSync(filePath);
|
|
988
|
+
if (stat.size <= 0) continue;
|
|
989
|
+
const signature = fileSignature(stat);
|
|
990
|
+
const cache = loadCodexFileCache(codexHome, filePath, signature);
|
|
991
|
+
const priorCache = cache || loadCodexFileCache(codexHome, filePath);
|
|
992
|
+
const priorTail = cache ? null : loadCodexFileTail(codexHome, filePath);
|
|
993
|
+
const file = {
|
|
994
|
+
codexHome,
|
|
995
|
+
filePath,
|
|
996
|
+
strict,
|
|
997
|
+
snapshotSize: stat.size,
|
|
998
|
+
signature,
|
|
999
|
+
cache,
|
|
1000
|
+
priorCache,
|
|
1001
|
+
priorTail,
|
|
1002
|
+
header: null,
|
|
1003
|
+
appendTail: null,
|
|
1004
|
+
};
|
|
1005
|
+
if (!cache && priorCache) file.appendTail = tailStateFor(file);
|
|
1006
|
+
files.push(file);
|
|
1007
|
+
} catch (err) {
|
|
1008
|
+
// The file may move to archived_sessions between discovery and stat.
|
|
1009
|
+
if (strict && err?.code !== 'ENOENT') {
|
|
1010
|
+
return {
|
|
1011
|
+
buckets: [], sessions: [], skipped: true,
|
|
1012
|
+
warnings: [`codex: 额外根目录读取失败,已保留上次同步数据: ${codexHome}`],
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
if (files.length === 0) return { buckets: [], sessions: [] };
|
|
1019
|
+
|
|
1020
|
+
// A warm cache gets a tiny rolling correctness audit. Never mix this into a
|
|
1021
|
+
// cold/resumed build: all files must already have complete results, and at
|
|
1022
|
+
// most one bounded file is re-read per invocation.
|
|
1023
|
+
const auditPaths = new Set();
|
|
1024
|
+
if (files.every(file => file.cache?.header && file.cache?.result)) {
|
|
1025
|
+
const cutoff = Date.now() - auditIntervalMs();
|
|
1026
|
+
const candidate = files
|
|
1027
|
+
.filter(file => file.snapshotSize <= auditMaxBytes())
|
|
1028
|
+
.filter(file => (file.cache.lastAuditedAt || 0) <= cutoff)
|
|
1029
|
+
.sort((a, b) => (a.cache.lastAuditedAt || 0) - (b.cache.lastAuditedAt || 0))[0];
|
|
1030
|
+
if (candidate) auditPaths.add(candidate.filePath);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// Cheap discovery: cached headers require no rollout read. On a cold build,
|
|
1034
|
+
// read only through the first session_meta so ordinary sessions can avoid
|
|
1035
|
+
// the former all-files replay-index pass.
|
|
1036
|
+
const candidatesById = new Map();
|
|
1037
|
+
for (let i = 0; i < files.length; i++) {
|
|
1038
|
+
const file = files[i];
|
|
1039
|
+
const reusableHeader = file.cache?.header || (file.appendTail ? file.priorCache?.header : null);
|
|
1040
|
+
if (reusableHeader && !auditPaths.has(file.filePath)) {
|
|
1041
|
+
file.header = reusableHeader;
|
|
1042
|
+
if (!file.cache) file.cache = { header: reusableHeader };
|
|
1043
|
+
cacheStats.headerHits++;
|
|
1044
|
+
} else {
|
|
1045
|
+
try {
|
|
1046
|
+
file.header = await readSessionHeader(file.filePath, file.snapshotSize);
|
|
1047
|
+
cacheStats.filesRead++;
|
|
1048
|
+
updateFileCache(file, { header: file.header });
|
|
1049
|
+
} catch {
|
|
1050
|
+
if (file.strict) {
|
|
1051
|
+
return {
|
|
1052
|
+
buckets: [], sessions: [], skipped: true,
|
|
1053
|
+
warnings: [`codex: 额外根目录读取失败,已保留上次同步数据: ${file.codexHome}`],
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
continue;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
if (file.header.sessionId) {
|
|
1060
|
+
if (!candidatesById.has(file.header.sessionId)) candidatesById.set(file.header.sessionId, []);
|
|
1061
|
+
candidatesById.get(file.header.sessionId).push(file);
|
|
1062
|
+
}
|
|
1063
|
+
if (overBudget() && i < files.length - 1) {
|
|
1064
|
+
return {
|
|
1065
|
+
buckets: [], sessions: [], skipped: true,
|
|
1066
|
+
indexing: { phase: 'discovery', completed: i + 1, total: files.length },
|
|
1067
|
+
cache: cacheStats,
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
const duplicateIds = new Set(
|
|
1073
|
+
[...candidatesById].filter(([, candidates]) => candidates.length > 1).map(([id]) => id)
|
|
1074
|
+
);
|
|
1075
|
+
const referencedParentIds = new Set();
|
|
1076
|
+
for (const file of files) {
|
|
1077
|
+
const parentId = file.header?.forkedFromId
|
|
1078
|
+
|| (file.header?.isSubagent ? file.header.parentThreadId : null);
|
|
1079
|
+
if (parentId) referencedParentIds.add(parentId);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// Only replay participants, their parents, corrupt-header files, and
|
|
1083
|
+
// duplicate physical copies need the full compact token index.
|
|
1084
|
+
const fileMeta = new Map();
|
|
1085
|
+
const needsIndex = new Set();
|
|
1086
|
+
for (const file of files) {
|
|
1087
|
+
const header = file.header;
|
|
1088
|
+
const required = !header
|
|
1089
|
+
|| !header.sessionId
|
|
1090
|
+
|| header.isSubagent
|
|
1091
|
+
|| header.forkedFromId != null
|
|
1092
|
+
|| header.parentThreadId != null
|
|
1093
|
+
|| referencedParentIds.has(header.sessionId)
|
|
1094
|
+
|| duplicateIds.has(header.sessionId);
|
|
1095
|
+
if (!required) {
|
|
1096
|
+
fileMeta.set(file.filePath, { ...header, filePath: file.filePath });
|
|
1097
|
+
continue;
|
|
1098
|
+
}
|
|
1099
|
+
needsIndex.add(file.filePath);
|
|
1100
|
+
let meta = auditPaths.has(file.filePath) ? null : file.cache?.index;
|
|
1101
|
+
if (meta) {
|
|
1102
|
+
cacheStats.indexHits++;
|
|
1103
|
+
} else {
|
|
1104
|
+
try {
|
|
1105
|
+
meta = await indexSessionFile(file.filePath, file.snapshotSize);
|
|
1106
|
+
cacheStats.filesRead++;
|
|
1107
|
+
updateFileCache(file, { index: meta });
|
|
1108
|
+
} catch {
|
|
1109
|
+
if (file.strict) {
|
|
1110
|
+
return {
|
|
1111
|
+
buckets: [], sessions: [], skipped: true,
|
|
1112
|
+
warnings: [`codex: 额外根目录读取失败,已保留上次同步数据: ${file.codexHome}`],
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
continue;
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
fileMeta.set(file.filePath, meta);
|
|
1119
|
+
if (overBudget()) {
|
|
1120
|
+
return {
|
|
1121
|
+
buckets: [], sessions: [], skipped: true,
|
|
1122
|
+
indexing: { phase: 'replay-index', completed: fileMeta.size, total: files.length },
|
|
1123
|
+
cache: cacheStats,
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// Select the most complete physical copy exactly as before. Unique ordinary
|
|
1129
|
+
// sessions have no full record count, but cannot compete with another copy.
|
|
1130
|
+
const sessionById = new Map();
|
|
1131
|
+
for (const file of files) {
|
|
1132
|
+
const meta = fileMeta.get(file.filePath);
|
|
1133
|
+
if (!meta?.sessionId) continue;
|
|
1134
|
+
const existing = sessionById.get(meta.sessionId);
|
|
1135
|
+
const count = meta.parsedRecordCount ?? 0;
|
|
1136
|
+
const existingCount = existing?.parsedRecordCount ?? 0;
|
|
1137
|
+
if (!existing || count > existingCount) sessionById.set(meta.sessionId, meta);
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
const results = [];
|
|
1141
|
+
for (let i = 0; i < files.length; i++) {
|
|
1142
|
+
const file = files[i];
|
|
1143
|
+
const fm = fileMeta.get(file.filePath);
|
|
1144
|
+
if (!fm) continue;
|
|
1145
|
+
if (fm.sessionId && sessionById.get(fm.sessionId)?.filePath !== file.filePath) continue;
|
|
1146
|
+
|
|
1147
|
+
const boundary = needsIndex.has(file.filePath)
|
|
1148
|
+
? replayBoundary(fm, sessionById)
|
|
1149
|
+
: { rawTokenCount: 0, recordIndex: null };
|
|
1150
|
+
const key = boundaryKey(boundary);
|
|
1151
|
+
let result = !auditPaths.has(file.filePath) && file.cache?.result?.boundaryKey === key
|
|
1152
|
+
? file.cache.result
|
|
1153
|
+
: null;
|
|
1154
|
+
if (result) {
|
|
1155
|
+
cacheStats.resultHits++;
|
|
1156
|
+
} else {
|
|
1157
|
+
const previousTail = !needsIndex.has(file.filePath) && !auditPaths.has(file.filePath)
|
|
1158
|
+
? file.appendTail
|
|
1159
|
+
: null;
|
|
1160
|
+
const parsed = await parseSessionFile(file.filePath, file.snapshotSize, fm, boundary, {
|
|
1161
|
+
previousTail,
|
|
1162
|
+
captureTail: !needsIndex.has(file.filePath),
|
|
1163
|
+
});
|
|
1164
|
+
cacheStats.filesRead++;
|
|
1165
|
+
if (previousTail) cacheStats.tailHits++;
|
|
1166
|
+
const { tail, ...summary } = parsed;
|
|
1167
|
+
if (tail) {
|
|
1168
|
+
try {
|
|
1169
|
+
saveCodexFileTail(file.codexHome, file.filePath, file.signature, tail);
|
|
1170
|
+
} catch {
|
|
1171
|
+
// Same fail-open rule as the summary cache: tail acceleration is
|
|
1172
|
+
// optional and a write failure must not fail the parser.
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
result = { boundaryKey: key, ...summary };
|
|
1176
|
+
updateFileCache(file, { result, lastAuditedAt: Date.now() });
|
|
1177
|
+
file.appendTail = null;
|
|
1178
|
+
file.priorTail = null;
|
|
1179
|
+
if (auditPaths.has(file.filePath)) cacheStats.audited++;
|
|
1180
|
+
}
|
|
1181
|
+
results.push(result);
|
|
1182
|
+
|
|
1183
|
+
if (overBudget() && i < files.length - 1) {
|
|
1184
|
+
return {
|
|
1185
|
+
buckets: [], sessions: [], skipped: true,
|
|
1186
|
+
indexing: { phase: 'usage', completed: i + 1, total: files.length },
|
|
1187
|
+
cache: cacheStats,
|
|
1188
|
+
};
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
return { ...mergeFileResults(results), cache: cacheStats };
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
export async function parse(options = {}) {
|
|
1196
|
+
const nativeResult = await parseNativeCodex(options);
|
|
1197
|
+
return mergeCindyHarnessUsage(nativeResult, readCindyHarnessUsage('codex'));
|
|
1198
|
+
}
|