@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.
Files changed (62) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +222 -0
  3. package/bin/pi-usage.js +69 -0
  4. package/data/models.dev-LICENSE +21 -0
  5. package/data/prices.json +2678 -0
  6. package/extensions/usage-report.js +36 -0
  7. package/package.json +51 -0
  8. package/src/analytics.js +189 -0
  9. package/src/collect.js +34 -0
  10. package/src/network.js +25 -0
  11. package/src/report.js +43 -0
  12. package/vendor/vibe-usage/NOTICE.md +58 -0
  13. package/vendor/vibe-usage/src/cindy-roots.js +85 -0
  14. package/vendor/vibe-usage/src/claude-roots.js +165 -0
  15. package/vendor/vibe-usage/src/cline-roots.js +40 -0
  16. package/vendor/vibe-usage/src/codex-roots.js +46 -0
  17. package/vendor/vibe-usage/src/craft-roots.js +15 -0
  18. package/vendor/vibe-usage/src/extra-roots.js +312 -0
  19. package/vendor/vibe-usage/src/parsers/aggregate.js +196 -0
  20. package/vendor/vibe-usage/src/parsers/alma.js +94 -0
  21. package/vendor/vibe-usage/src/parsers/amp.js +156 -0
  22. package/vendor/vibe-usage/src/parsers/antigravity-db.js +359 -0
  23. package/vendor/vibe-usage/src/parsers/antigravity.js +530 -0
  24. package/vendor/vibe-usage/src/parsers/cindy-ledger.js +157 -0
  25. package/vendor/vibe-usage/src/parsers/claude-code.js +372 -0
  26. package/vendor/vibe-usage/src/parsers/cline.js +92 -0
  27. package/vendor/vibe-usage/src/parsers/codex-cache.js +138 -0
  28. package/vendor/vibe-usage/src/parsers/codex.js +1198 -0
  29. package/vendor/vibe-usage/src/parsers/contract.js +55 -0
  30. package/vendor/vibe-usage/src/parsers/copilot-cli.js +128 -0
  31. package/vendor/vibe-usage/src/parsers/craft-agent.js +21 -0
  32. package/vendor/vibe-usage/src/parsers/cursor.js +262 -0
  33. package/vendor/vibe-usage/src/parsers/dimagent.js +127 -0
  34. package/vendor/vibe-usage/src/parsers/droid.js +113 -0
  35. package/vendor/vibe-usage/src/parsers/dsh.js +563 -0
  36. package/vendor/vibe-usage/src/parsers/fs-utils.js +36 -0
  37. package/vendor/vibe-usage/src/parsers/gemini-cli.js +190 -0
  38. package/vendor/vibe-usage/src/parsers/grok.js +395 -0
  39. package/vendor/vibe-usage/src/parsers/hermes.js +123 -0
  40. package/vendor/vibe-usage/src/parsers/index.js +61 -0
  41. package/vendor/vibe-usage/src/parsers/kimi-code.js +467 -0
  42. package/vendor/vibe-usage/src/parsers/kiro.js +788 -0
  43. package/vendor/vibe-usage/src/parsers/mcode.js +182 -0
  44. package/vendor/vibe-usage/src/parsers/mimocode.js +88 -0
  45. package/vendor/vibe-usage/src/parsers/omp.js +10 -0
  46. package/vendor/vibe-usage/src/parsers/openclaw.js +142 -0
  47. package/vendor/vibe-usage/src/parsers/opencode.js +151 -0
  48. package/vendor/vibe-usage/src/parsers/pi-coding-agent.js +27 -0
  49. package/vendor/vibe-usage/src/parsers/pi-session-jsonl.js +166 -0
  50. package/vendor/vibe-usage/src/parsers/qwen-code.js +122 -0
  51. package/vendor/vibe-usage/src/parsers/roo-code.js +123 -0
  52. package/vendor/vibe-usage/src/parsers/sqlite.js +148 -0
  53. package/vendor/vibe-usage/src/parsers/trae-cli.js +171 -0
  54. package/vendor/vibe-usage/src/parsers/workbuddy.js +322 -0
  55. package/vendor/vibe-usage/src/parsers/zcode.js +115 -0
  56. package/vendor/vibe-usage/src/pi-roots.js +125 -0
  57. package/vendor/vibe-usage/src/tools.js +422 -0
  58. package/vendor/vibe-usage/src/workbuddy-roots.js +22 -0
  59. package/vendor/vibe-usage/upstream-files.json +48 -0
  60. package/web/report.css +10 -0
  61. package/web/report.html +81 -0
  62. package/web/report.js +310 -0
@@ -0,0 +1,372 @@
1
+ import { createReadStream, readdirSync, statSync } from 'node:fs';
2
+ import { createInterface } from 'node:readline';
3
+ import { join, basename, sep } from 'node:path';
4
+ import {
5
+ accumulateSessionEvent,
6
+ aggregateToBuckets,
7
+ createSessionAccumulator,
8
+ extractSessions,
9
+ finalizeSessionAccumulator,
10
+ sessionAccumulatorIsOrdered,
11
+ } from './aggregate.js';
12
+ import { projectFromCwd, toCount } from './fs-utils.js';
13
+ import { getClaudeRoots } from '../claude-roots.js';
14
+ import { requestTypeFor, mergeRequestTypes } from '../../../../src/analytics.js';
15
+
16
+ const MAX_WARNINGS = 20;
17
+
18
+ function addWarning(ctx, message) {
19
+ ctx.incomplete = true;
20
+ if (ctx.warnings.length < MAX_WARNINGS) ctx.warnings.push(message);
21
+ }
22
+
23
+ /** Recursively collect JSONL files without making one unreadable branch fatal. */
24
+ function findJsonlFiles(dir, ctx) {
25
+ let entries;
26
+ try {
27
+ entries = readdirSync(dir, { withFileTypes: true });
28
+ } catch (err) {
29
+ if (err?.code !== 'ENOENT') {
30
+ addWarning(ctx, `Claude Code: cannot read directory ${dir}: ${err.message}`);
31
+ }
32
+ return [];
33
+ }
34
+
35
+ const results = [];
36
+ for (const entry of entries) {
37
+ const fullPath = join(dir, entry.name);
38
+ if (entry.isDirectory()) {
39
+ for (const nested of findJsonlFiles(fullPath, ctx)) results.push(nested);
40
+ } else if (entry.name.endsWith('.jsonl')) {
41
+ results.push(fullPath);
42
+ }
43
+ }
44
+ return results;
45
+ }
46
+
47
+ function projectRelativePath(filePath, projectsDir) {
48
+ const prefix = projectsDir + sep;
49
+ return filePath.startsWith(prefix) ? filePath.slice(prefix.length) : null;
50
+ }
51
+
52
+ /** Best-effort fallback for old records without cwd. */
53
+ function projectFromRelative(relative) {
54
+ if (!relative) return 'unknown';
55
+ const firstSegment = relative.split(sep)[0];
56
+ if (!firstSegment) return 'unknown';
57
+ const parts = firstSegment.split('-').filter(Boolean);
58
+ return parts.at(-1) || 'unknown';
59
+ }
60
+
61
+ function cacheCreationTokens(usage) {
62
+ const direct = toCount(usage.cache_creation_input_tokens);
63
+ const breakdown = usage.cache_creation || {};
64
+ const split =
65
+ toCount(breakdown.ephemeral_5m_input_tokens) +
66
+ toCount(breakdown.ephemeral_1h_input_tokens);
67
+ // Current Claude logs carry both the total and its TTL breakdown. max()
68
+ // avoids double-counting while remaining tolerant of partially populated logs.
69
+ return Math.max(direct, split);
70
+ }
71
+
72
+ function candidateIsBetter(next, current) {
73
+ if (!current) return true;
74
+ if (next.size !== current.size) return next.size > current.size;
75
+ if (next.mtimeMs !== current.mtimeMs) return next.mtimeMs > current.mtimeMs;
76
+ return next.filePath.localeCompare(current.filePath) < 0;
77
+ }
78
+
79
+ /**
80
+ * Group physical files by logical session id and keep candidates ordered by
81
+ * completeness. A session copied between ~/.claude and CLAUDE_CONFIG_DIR must
82
+ * use its largest/newest copy, not whichever root happened to be scanned first.
83
+ */
84
+ function collectCandidates(roots, directoryName, ctx) {
85
+ const groups = new Map();
86
+ for (const root of roots) {
87
+ const baseDir = join(root, directoryName);
88
+ for (const filePath of findJsonlFiles(baseDir, ctx)) {
89
+ let stat;
90
+ try {
91
+ stat = statSync(filePath);
92
+ } catch (err) {
93
+ addWarning(ctx, `Claude Code: cannot stat ${filePath}: ${err.message}`);
94
+ continue;
95
+ }
96
+ const sessionId = basename(filePath, '.jsonl');
97
+ const relative = projectRelativePath(filePath, baseDir);
98
+ const candidate = {
99
+ filePath,
100
+ sessionId,
101
+ size: stat.size,
102
+ mtimeMs: stat.mtimeMs,
103
+ fallbackProject: directoryName === 'projects'
104
+ ? projectFromRelative(relative)
105
+ : 'unknown',
106
+ };
107
+ const group = groups.get(sessionId) || [];
108
+ group.push(candidate);
109
+ groups.set(sessionId, group);
110
+ }
111
+ }
112
+ for (const group of groups.values()) {
113
+ group.sort((a, b) => candidateIsBetter(a, b) ? -1 : candidateIsBetter(b, a) ? 1 : 0);
114
+ }
115
+ return groups;
116
+ }
117
+
118
+ /** Read only the file size captured during discovery, so live appends wait. */
119
+ async function readJsonl(candidate, onObject) {
120
+ if (candidate.size === 0) return;
121
+ const stream = createReadStream(candidate.filePath, {
122
+ encoding: 'utf8',
123
+ start: 0,
124
+ end: candidate.size - 1,
125
+ });
126
+ let streamError = null;
127
+ stream.on('error', (err) => { streamError = err; });
128
+ const lines = createInterface({ input: stream, crlfDelay: Infinity });
129
+
130
+ try {
131
+ for await (const line of lines) {
132
+ if (!line.trim()) continue;
133
+ try {
134
+ onObject(JSON.parse(line));
135
+ } catch {
136
+ // Claude may be appending the final JSONL record while we snapshot it.
137
+ // A later sync will see the complete line; malformed historical lines
138
+ // are isolated instead of taking the whole parser down.
139
+ }
140
+ }
141
+ if (streamError) throw streamError;
142
+ } finally {
143
+ lines.close();
144
+ stream.destroy();
145
+ }
146
+ }
147
+
148
+ function timingEvent(obj, sessionId, project) {
149
+ if (
150
+ obj.type !== 'user' &&
151
+ obj.type !== 'assistant' &&
152
+ obj.type !== 'tool_use' &&
153
+ obj.type !== 'tool_result'
154
+ ) return null;
155
+ if (!obj.timestamp) return null;
156
+ const timestamp = new Date(obj.timestamp);
157
+ if (Number.isNaN(timestamp.getTime())) return null;
158
+ return {
159
+ sessionId,
160
+ source: 'claude-code',
161
+ project,
162
+ timestamp,
163
+ role: obj.type === 'user' ? 'user' : 'assistant',
164
+ };
165
+ }
166
+ async function collectTimingEvents(candidate, projectForObject) {
167
+ const events = [];
168
+ await readJsonl(candidate, (obj) => {
169
+ const event = timingEvent(
170
+ obj,
171
+ candidate.sessionId,
172
+ projectForObject(obj),
173
+ );
174
+ if (event) events.push(event);
175
+ });
176
+ return events;
177
+ }
178
+
179
+ async function finalizeCandidateSession(
180
+ candidate,
181
+ accumulator,
182
+ projectForObject,
183
+ projectOverride,
184
+ ) {
185
+ if (sessionAccumulatorIsOrdered(accumulator)) {
186
+ return finalizeSessionAccumulator(
187
+ accumulator,
188
+ candidate.sessionId,
189
+ projectOverride,
190
+ );
191
+ }
192
+
193
+ // JSONL is normally append-ordered. Preserve the old sort semantics for an
194
+ // unusual copied/rewritten file without retaining every event on the common
195
+ // path: re-read only that candidate and let extractSessions() sort it.
196
+ const events = await collectTimingEvents(candidate, projectForObject);
197
+ return extractSessions(events)[0] || null;
198
+ }
199
+
200
+ async function scanProjectCandidate(candidate) {
201
+ const usageEntries = {
202
+ entriesByKey: new Map(),
203
+ anonymousEntries: [],
204
+ };
205
+ const sessionAccumulator = createSessionAccumulator();
206
+ let lastModel = null;
207
+ let sessionProject = candidate.fallbackProject;
208
+ let foundSessionCwd = false;
209
+
210
+ await readJsonl(candidate, (obj) => {
211
+ // cwd can change after Claude runs `cd`; project attribution should remain
212
+ // the directory where this session started, not fragment into subfolders.
213
+ if (!foundSessionCwd && typeof obj.cwd === 'string' && obj.cwd.trim()) {
214
+ sessionProject = projectFromCwd(obj.cwd, candidate.fallbackProject);
215
+ foundSessionCwd = true;
216
+ }
217
+ const event = timingEvent(obj, candidate.sessionId, sessionProject);
218
+ if (event) accumulateSessionEvent(sessionAccumulator, event);
219
+
220
+ if (obj.type !== 'assistant' || !obj.message?.usage || !obj.timestamp) return;
221
+ const timestamp = new Date(obj.timestamp);
222
+ if (Number.isNaN(timestamp.getTime())) return;
223
+
224
+ const usage = obj.message.usage;
225
+ const rawModel = typeof obj.message.model === 'string'
226
+ ? obj.message.model.trim()
227
+ : '';
228
+ if (rawModel && rawModel !== '<synthetic>') lastModel = rawModel;
229
+ const model = rawModel && rawModel !== '<synthetic>'
230
+ ? rawModel
231
+ : lastModel || 'claude-unknown';
232
+ const inputTokens = toCount(usage.input_tokens) + cacheCreationTokens(usage);
233
+ const outputTokens = toCount(usage.output_tokens);
234
+ const cachedInputTokens = toCount(usage.cache_read_input_tokens);
235
+ const usageScore = inputTokens + outputTokens + cachedInputTokens;
236
+
237
+ // Synthetic bookkeeping messages are common and carry zero usage. Do not
238
+ // inflate the CLI's bucket count with rows the server will discard anyway.
239
+ if (usageScore === 0) return;
240
+
241
+ mergeUsageEntry(usageEntries, {
242
+ dedupeKey: usageDedupeKey(obj),
243
+ usageScore,
244
+ source: 'claude-code',
245
+ requestType: requestTypeFor(obj.message),
246
+ model,
247
+ project: sessionProject,
248
+ timestamp,
249
+ inputTokens,
250
+ outputTokens,
251
+ cachedInputTokens,
252
+ reasoningOutputTokens: 0,
253
+ });
254
+ });
255
+
256
+ // A cwd can appear after initial metadata/messages. Normalize the completed
257
+ // session in one place so early records receive the same project label.
258
+ for (const entry of usageEntries.anonymousEntries) entry.project = sessionProject;
259
+ for (const entry of usageEntries.entriesByKey.values()) entry.project = sessionProject;
260
+ const session = await finalizeCandidateSession(
261
+ candidate,
262
+ sessionAccumulator,
263
+ () => sessionProject,
264
+ sessionProject,
265
+ );
266
+ return { usageEntries, session };
267
+ }
268
+
269
+ async function scanTranscriptCandidate(candidate) {
270
+ const sessionAccumulator = createSessionAccumulator();
271
+ const projectForObject = (obj) => projectFromCwd(obj.cwd, 'unknown');
272
+ await readJsonl(candidate, (obj) => {
273
+ const event = timingEvent(
274
+ obj,
275
+ candidate.sessionId,
276
+ projectForObject(obj),
277
+ );
278
+ if (event) accumulateSessionEvent(sessionAccumulator, event);
279
+ });
280
+ const session = await finalizeCandidateSession(
281
+ candidate,
282
+ sessionAccumulator,
283
+ projectForObject,
284
+ );
285
+ return { session };
286
+ }
287
+
288
+ async function scanBestCandidate(candidates, scanner, ctx) {
289
+ for (const candidate of candidates) {
290
+ try {
291
+ return await scanner(candidate);
292
+ } catch (err) {
293
+ addWarning(ctx, `Claude Code: cannot read ${candidate.filePath}: ${err.message}`);
294
+ }
295
+ }
296
+ return null;
297
+ }
298
+
299
+ // One API call is written as several assistant lines - one per content block -
300
+ // that share `message.id`/`requestId` and repeat the same `usage` object, so a
301
+ // per-line key counts the same call once per block. Streaming also emits an
302
+ // early partial line (lower `output_tokens`) before the final one under that
303
+ // same id. Keying on the call identity collapses both, and the existing
304
+ // highest-usageScore wins rule then keeps the final, complete payload.
305
+ // Records without either id (older logs) fall back to the line uuid.
306
+ function usageDedupeKey(obj) {
307
+ const messageId = typeof obj.message?.id === 'string' ? obj.message.id.trim() : '';
308
+ const requestId = typeof obj.requestId === 'string' ? obj.requestId.trim() : '';
309
+ if (messageId || requestId) return `call:${messageId}\u0000${requestId}`;
310
+ return typeof obj.uuid === 'string' && obj.uuid ? obj.uuid : null;
311
+ }
312
+
313
+ function mergeUsageEntry(ctx, entry) {
314
+ if (!entry.dedupeKey) {
315
+ ctx.anonymousEntries.push(entry);
316
+ return;
317
+ }
318
+ const current = ctx.entriesByKey.get(entry.dedupeKey);
319
+ // Claude sometimes copies the same record into another session with zeroed
320
+ // usage. Keep the most complete payload, independent of directory order.
321
+ const requestType = mergeRequestTypes(current?.requestType, entry.requestType);
322
+ if (!current || entry.usageScore > current.usageScore) {
323
+ ctx.entriesByKey.set(entry.dedupeKey, { ...entry, requestType });
324
+ } else current.requestType = requestType;
325
+ }
326
+
327
+ function mergeUsageEntries(target, source) {
328
+ for (const entry of source.anonymousEntries) mergeUsageEntry(target, entry);
329
+ for (const entry of source.entriesByKey.values()) mergeUsageEntry(target, entry);
330
+ }
331
+
332
+ function* iterateUsageEntries(ctx) {
333
+ for (const entry of ctx.anonymousEntries) yield entry;
334
+ for (const entry of ctx.entriesByKey.values()) yield entry;
335
+ }
336
+
337
+ export async function parse() {
338
+ const ctx = {
339
+ entriesByKey: new Map(),
340
+ anonymousEntries: [],
341
+ sessions: [],
342
+ warnings: [],
343
+ incomplete: false,
344
+ };
345
+ const roots = getClaudeRoots({
346
+ onWarning: (message) => addWarning(ctx, message),
347
+ });
348
+ const projectGroups = collectCandidates(roots, 'projects', ctx);
349
+ const projectSessionIds = new Set();
350
+
351
+ for (const [sessionId, candidates] of projectGroups) {
352
+ const parsed = await scanBestCandidate(candidates, scanProjectCandidate, ctx);
353
+ if (!parsed) continue;
354
+ projectSessionIds.add(sessionId);
355
+ if (parsed.session) ctx.sessions.push(parsed.session);
356
+ mergeUsageEntries(ctx, parsed.usageEntries);
357
+ }
358
+
359
+ const transcriptGroups = collectCandidates(roots, 'transcripts', ctx);
360
+ for (const [sessionId, candidates] of transcriptGroups) {
361
+ if (projectSessionIds.has(sessionId)) continue;
362
+ const parsed = await scanBestCandidate(candidates, scanTranscriptCandidate, ctx);
363
+ if (parsed?.session) ctx.sessions.push(parsed.session);
364
+ }
365
+
366
+ return {
367
+ buckets: aggregateToBuckets(iterateUsageEntries(ctx)),
368
+ sessions: ctx.sessions,
369
+ ...(ctx.incomplete ? { skipped: true } : {}),
370
+ ...(ctx.warnings.length > 0 ? { warnings: ctx.warnings } : {}),
371
+ };
372
+ }
@@ -0,0 +1,92 @@
1
+ import { statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
4
+ import { readJsonSafe, projectFromPath } from './fs-utils.js';
5
+ import { findClineDataDirs } from '../cline-roots.js';
6
+
7
+ export async function parse() {
8
+ const extDirs = findClineDataDirs();
9
+ if (extDirs.length === 0) return { buckets: [], sessions: [] };
10
+
11
+ const entries = [];
12
+ const events = [];
13
+
14
+ const candidates = new Map();
15
+ for (const extDir of extDirs) {
16
+ const history = readJsonSafe(join(extDir, 'state', 'taskHistory.json'));
17
+ if (!Array.isArray(history)) continue;
18
+
19
+ for (const item of history) {
20
+ if (!item || typeof item !== 'object' || !item.id) continue;
21
+ const taskId = String(item.id);
22
+ const messagesPath = join(extDir, 'tasks', taskId, 'ui_messages.json');
23
+ let stat;
24
+ try {
25
+ stat = statSync(messagesPath);
26
+ } catch {
27
+ continue;
28
+ }
29
+ const key = item.ulid ? String(item.ulid) : taskId;
30
+ const next = { item, taskId, messagesPath, size: stat.size, mtimeMs: stat.mtimeMs };
31
+ const current = candidates.get(key);
32
+ if (!current || next.size > current.size || (
33
+ next.size === current.size && next.mtimeMs > current.mtimeMs
34
+ )) candidates.set(key, next);
35
+ }
36
+ }
37
+
38
+ for (const { item, taskId, messagesPath } of candidates.values()) {
39
+ try {
40
+ const project = projectFromPath(
41
+ item.cwdOnTaskInitialization || item.shadowGitConfigWorkTree || item.cwd,
42
+ );
43
+ const fallbackModel = (item.modelId && String(item.modelId).trim()) || 'cline-unknown';
44
+
45
+ const messages = readJsonSafe(messagesPath);
46
+ if (!Array.isArray(messages)) continue;
47
+
48
+ for (const msg of messages) {
49
+ if (!msg || typeof msg !== 'object') continue;
50
+ const ts = Number(msg.ts);
51
+ if (!Number.isFinite(ts)) continue;
52
+ const timestamp = new Date(ts);
53
+
54
+ if (msg.type === 'say' && msg.say === 'api_req_started') {
55
+ let info = null;
56
+ try { info = JSON.parse(msg.text); } catch { /* skip */ }
57
+ if (!info) continue;
58
+
59
+ const inputTokens = Math.max(0, Number(info.tokensIn) || 0);
60
+ const outputTokens = Math.max(0, Number(info.tokensOut) || 0);
61
+ const cacheWrites = Math.max(0, Number(info.cacheWrites) || 0);
62
+ const cacheReads = Math.max(0, Number(info.cacheReads) || 0);
63
+ if (inputTokens + outputTokens + cacheWrites + cacheReads === 0) continue;
64
+
65
+ // Newer Cline embeds the model id directly on the api_req_started payload.
66
+ const model = (info.model && String(info.model).trim()) || fallbackModel;
67
+
68
+ // Bucket schema (matches Cursor's CSV semantics):
69
+ // inputTokens = non-cache input + cache-write tokens (both billed as input)
70
+ // cachedInputTokens = cache-read tokens (10% input rate)
71
+ entries.push({
72
+ source: 'cline',
73
+ model,
74
+ project,
75
+ timestamp,
76
+ inputTokens: inputTokens + cacheWrites,
77
+ outputTokens,
78
+ cachedInputTokens: cacheReads,
79
+ reasoningOutputTokens: 0,
80
+ });
81
+ events.push({ sessionId: taskId, source: 'cline', project, timestamp, role: 'assistant' });
82
+ } else if (msg.type === 'ask' || (msg.type === 'say' && msg.say === 'user_feedback')) {
83
+ events.push({ sessionId: taskId, source: 'cline', project, timestamp, role: 'user' });
84
+ }
85
+ }
86
+ } catch {
87
+ // Skip this task; keep going for the rest of the history.
88
+ }
89
+ }
90
+
91
+ return { buckets: aggregateToBuckets(entries), sessions: extractSessions(events) };
92
+ }
@@ -0,0 +1,138 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ renameSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from 'node:fs';
9
+ import { createHash, randomBytes } from 'node:crypto';
10
+ import { homedir } from 'node:os';
11
+ import { join } from 'node:path';
12
+
13
+ // Parser caches are disposable derived data. Keep their schema/version fully
14
+ // separate from ~/.vibe-usage/state.json, whose hashes are the authoritative
15
+ // record of successful uploads and must remain backward-compatible.
16
+ export const CODEX_CACHE_SCHEMA_VERSION = 1;
17
+ export const CODEX_PARSER_ALGORITHM_VERSION = 4;
18
+
19
+ function hash(value, length = 24) {
20
+ return createHash('sha256').update(value).digest('hex').slice(0, length);
21
+ }
22
+
23
+ export function codexCacheEnabled() {
24
+ return process.env.VIBE_USAGE_CODEX_CACHE !== '0';
25
+ }
26
+
27
+ export function fileSignature(stat) {
28
+ return {
29
+ size: stat.size,
30
+ mtimeMs: stat.mtimeMs,
31
+ dev: String(stat.dev),
32
+ ino: String(stat.ino),
33
+ };
34
+ }
35
+
36
+ function sameSignature(a, b) {
37
+ return a?.size === b.size
38
+ && a?.mtimeMs === b.mtimeMs
39
+ && a?.dev === b.dev
40
+ && a?.ino === b.ino;
41
+ }
42
+
43
+ export function codexCacheDir(codexHome) {
44
+ const base = process.env.PI_USAGE_CACHE_DIR?.trim()
45
+ || join(homedir(), '.pi', 'usage', 'cache');
46
+ return join(base, 'codex', `root-${hash(codexHome)}`);
47
+ }
48
+
49
+ function entryPath(codexHome, filePath) {
50
+ return join(codexCacheDir(codexHome), `${hash(filePath, 32)}.json`);
51
+ }
52
+
53
+ function tailPath(codexHome, filePath) {
54
+ return join(codexCacheDir(codexHome), `${hash(filePath, 32)}.tail.json`);
55
+ }
56
+
57
+ export function loadCodexFileCache(codexHome, filePath, signature) {
58
+ if (!codexCacheEnabled()) return null;
59
+ const path = entryPath(codexHome, filePath);
60
+ if (!existsSync(path)) return null;
61
+ try {
62
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
63
+ if (parsed.schemaVersion !== CODEX_CACHE_SCHEMA_VERSION) return null;
64
+ if (parsed.algorithmVersion !== CODEX_PARSER_ALGORITHM_VERSION) return null;
65
+ if (parsed.filePath !== filePath) return null;
66
+ if (signature && !sameSignature(parsed.signature, signature)) return null;
67
+ return parsed;
68
+ } catch {
69
+ // Cache corruption is a performance miss, never a correctness failure.
70
+ return null;
71
+ }
72
+ }
73
+
74
+ export function saveCodexFileCache(codexHome, filePath, signature, data) {
75
+ if (!codexCacheEnabled()) return;
76
+ const dir = codexCacheDir(codexHome);
77
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
78
+ const path = entryPath(codexHome, filePath);
79
+ const tempPath = `${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
80
+ const payload = {
81
+ schemaVersion: CODEX_CACHE_SCHEMA_VERSION,
82
+ algorithmVersion: CODEX_PARSER_ALGORITHM_VERSION,
83
+ filePath,
84
+ signature,
85
+ ...data,
86
+ };
87
+ try {
88
+ writeFileSync(tempPath, `${JSON.stringify(payload)}\n`, { encoding: 'utf8', mode: 0o600 });
89
+ renameSync(tempPath, path);
90
+ } finally {
91
+ // A killed writer can leave a unique temp file; a normal failed writer
92
+ // should not. rmSync is safe here because the target is this call's exact,
93
+ // random temporary path inside the versioned cache directory.
94
+ rmSync(tempPath, { force: true });
95
+ }
96
+ }
97
+
98
+ export function loadCodexFileTail(codexHome, filePath) {
99
+ if (!codexCacheEnabled()) return null;
100
+ const path = tailPath(codexHome, filePath);
101
+ if (!existsSync(path)) return null;
102
+ try {
103
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
104
+ if (parsed.schemaVersion !== CODEX_CACHE_SCHEMA_VERSION) return null;
105
+ if (parsed.algorithmVersion !== CODEX_PARSER_ALGORITHM_VERSION) return null;
106
+ if (parsed.filePath !== filePath || !parsed.signature || !parsed.tail) return null;
107
+ return parsed;
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ export function saveCodexFileTail(codexHome, filePath, signature, tail) {
114
+ if (!codexCacheEnabled() || !tail) return;
115
+ const dir = codexCacheDir(codexHome);
116
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
117
+ const path = tailPath(codexHome, filePath);
118
+ const tempPath = `${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
119
+ const payload = {
120
+ schemaVersion: CODEX_CACHE_SCHEMA_VERSION,
121
+ algorithmVersion: CODEX_PARSER_ALGORITHM_VERSION,
122
+ filePath,
123
+ signature,
124
+ tail,
125
+ };
126
+ try {
127
+ writeFileSync(tempPath, `${JSON.stringify(payload)}\n`, { encoding: 'utf8', mode: 0o600 });
128
+ renameSync(tempPath, path);
129
+ } finally {
130
+ rmSync(tempPath, { force: true });
131
+ }
132
+ }
133
+
134
+ export function removeCodexFileCache(codexHome, filePath) {
135
+ if (!codexCacheEnabled()) return;
136
+ rmSync(entryPath(codexHome, filePath), { force: true });
137
+ rmSync(tailPath(codexHome, filePath), { force: true });
138
+ }