@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,36 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
|
|
4
|
+
// Small shared filesystem/parsing helpers used across parsers. Keeping them in
|
|
5
|
+
// one place removes the same ~10-line functions copied into every parser.
|
|
6
|
+
|
|
7
|
+
/** Read and parse a JSON file, returning null on any failure. */
|
|
8
|
+
export function readJsonSafe(path) {
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Last path component (project name), or 'unknown'. */
|
|
17
|
+
export function projectFromPath(absPath) {
|
|
18
|
+
if (!absPath || typeof absPath !== 'string') return 'unknown';
|
|
19
|
+
const trimmed = absPath.replace(/[\\/]+$/, '');
|
|
20
|
+
const name = basename(trimmed);
|
|
21
|
+
return name || 'unknown';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Last path component of a cwd value (works for both Unix and Windows paths). */
|
|
25
|
+
export function projectFromCwd(cwd, fallback = 'unknown') {
|
|
26
|
+
if (typeof cwd !== 'string') return fallback;
|
|
27
|
+
const trimmed = cwd.trim().replace(/[\\/]+$/, '');
|
|
28
|
+
if (!trimmed) return fallback;
|
|
29
|
+
return trimmed.split(/[\\/]/).filter(Boolean).at(-1) || fallback;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Coerce a token count to a finite positive number, else 0. */
|
|
33
|
+
export function toCount(value) {
|
|
34
|
+
const n = Number(value);
|
|
35
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
36
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join, basename } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
5
|
+
|
|
6
|
+
const TMP_DIR = join(homedir(), '.gemini', 'tmp');
|
|
7
|
+
|
|
8
|
+
// Gemini CLI session storage:
|
|
9
|
+
// ~/.gemini/tmp/<project_hash>/chats/session-<ts>-<id>.jsonl (current, v0.39+)
|
|
10
|
+
// ~/.gemini/tmp/<project_hash>/chats/session-<ts>-<id>.json (legacy, single JSON object)
|
|
11
|
+
// ~/.gemini/tmp/<project_hash>/chats/<parent_id>/<sub_id>.jsonl (subagent sessions, nested)
|
|
12
|
+
// The .jsonl migration (PR #23749, ~v0.39.0) made the old .json-only glob miss every new
|
|
13
|
+
// session — collect both extensions, and recurse one level for nested subagent files.
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Walk each project's chats/ directory and collect every session file
|
|
17
|
+
* (both .json and .jsonl), descending into subagent subdirectories.
|
|
18
|
+
*/
|
|
19
|
+
function findSessionFiles(baseDir) {
|
|
20
|
+
const results = [];
|
|
21
|
+
if (!existsSync(baseDir)) return results;
|
|
22
|
+
|
|
23
|
+
let projectDirs;
|
|
24
|
+
try {
|
|
25
|
+
projectDirs = readdirSync(baseDir, { withFileTypes: true });
|
|
26
|
+
} catch {
|
|
27
|
+
return results;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for (const entry of projectDirs) {
|
|
31
|
+
if (!entry.isDirectory()) continue;
|
|
32
|
+
collectChatFiles(join(baseDir, entry.name, 'chats'), results, 0);
|
|
33
|
+
}
|
|
34
|
+
return results;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function collectChatFiles(dir, out, depth) {
|
|
38
|
+
if (depth > 2) return; // chats/ + nested subagent dirs is as deep as it goes
|
|
39
|
+
let entries;
|
|
40
|
+
try {
|
|
41
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
42
|
+
} catch {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
for (const e of entries) {
|
|
46
|
+
const full = join(dir, e.name);
|
|
47
|
+
if (e.isDirectory()) {
|
|
48
|
+
collectChatFiles(full, out, depth + 1);
|
|
49
|
+
} else if (e.name.endsWith('.jsonl') || e.name.endsWith('.json')) {
|
|
50
|
+
out.push(full);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Read a session file into a uniform { messages, directories } shape.
|
|
57
|
+
* .jsonl: line 1 is session metadata, each following line is one record.
|
|
58
|
+
* .json: a single ConversationRecord object with a messages[] array.
|
|
59
|
+
*/
|
|
60
|
+
function readRecords(filePath) {
|
|
61
|
+
let raw;
|
|
62
|
+
try {
|
|
63
|
+
raw = readFileSync(filePath, 'utf-8');
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (filePath.endsWith('.jsonl')) {
|
|
69
|
+
const messages = [];
|
|
70
|
+
let directories = null;
|
|
71
|
+
for (const line of raw.split('\n')) {
|
|
72
|
+
const trimmed = line.trim();
|
|
73
|
+
if (!trimmed) continue;
|
|
74
|
+
let obj;
|
|
75
|
+
try {
|
|
76
|
+
obj = JSON.parse(trimmed);
|
|
77
|
+
} catch {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
// The metadata line carries directories; message lines carry a `type`.
|
|
81
|
+
if (!directories && Array.isArray(obj.directories)) directories = obj.directories;
|
|
82
|
+
if (typeof obj.type === 'string' || typeof obj.role === 'string') messages.push(obj);
|
|
83
|
+
}
|
|
84
|
+
return { messages, directories };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let data;
|
|
88
|
+
try {
|
|
89
|
+
data = JSON.parse(raw);
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
messages: data.messages || data.history || [],
|
|
95
|
+
directories: Array.isArray(data.directories) ? data.directories : null,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Model/assistant messages are recorded as type 'gemini'; user turns as 'user'.
|
|
100
|
+
// info/error/warning are system noise and skipped. `role` is accepted as a
|
|
101
|
+
// fallback for any older format that used it.
|
|
102
|
+
function classifyRole(msg) {
|
|
103
|
+
const t = msg.type ?? msg.role;
|
|
104
|
+
if (t === 'user') return 'user';
|
|
105
|
+
if (t === 'gemini' || t === 'model' || t === 'assistant') return 'assistant';
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Tokens live in msg.tokens.{input,output,cached,thoughts} (TokensSummary, where
|
|
110
|
+
// `input` already includes cached). Fall back to the raw Gemini API usageMetadata
|
|
111
|
+
// shape for any legacy record that stored it.
|
|
112
|
+
function extractTokens(msg) {
|
|
113
|
+
const t = msg.tokens;
|
|
114
|
+
if (t) {
|
|
115
|
+
const cached = t.cached || 0;
|
|
116
|
+
const thoughts = t.thoughts || 0;
|
|
117
|
+
return {
|
|
118
|
+
inputTokens: (t.input || 0) - cached,
|
|
119
|
+
outputTokens: (t.output || 0) - thoughts,
|
|
120
|
+
cachedInputTokens: cached,
|
|
121
|
+
reasoningOutputTokens: thoughts,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const u = msg.usageMetadata || msg.usage;
|
|
125
|
+
if (u) {
|
|
126
|
+
const cached = u.cachedContentTokenCount || 0;
|
|
127
|
+
const thoughts = u.thoughtsTokenCount || 0;
|
|
128
|
+
return {
|
|
129
|
+
inputTokens: (u.promptTokenCount || u.input_tokens || 0) - cached,
|
|
130
|
+
outputTokens: (u.candidatesTokenCount || u.output_tokens || 0) - thoughts,
|
|
131
|
+
cachedInputTokens: cached,
|
|
132
|
+
reasoningOutputTokens: thoughts,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function projectFromDirectories(directories) {
|
|
139
|
+
if (!directories || directories.length === 0) return 'unknown';
|
|
140
|
+
const first = directories[0];
|
|
141
|
+
if (!first) return 'unknown';
|
|
142
|
+
return basename(String(first).replace(/[\\/]+$/, '')) || 'unknown';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function parse() {
|
|
146
|
+
const sessionFiles = findSessionFiles(TMP_DIR);
|
|
147
|
+
if (sessionFiles.length === 0) return { buckets: [], sessions: [] };
|
|
148
|
+
|
|
149
|
+
const entries = [];
|
|
150
|
+
const sessionEvents = [];
|
|
151
|
+
|
|
152
|
+
for (const filePath of sessionFiles) {
|
|
153
|
+
const record = readRecords(filePath);
|
|
154
|
+
if (!record) continue;
|
|
155
|
+
|
|
156
|
+
const project = projectFromDirectories(record.directories);
|
|
157
|
+
|
|
158
|
+
for (const msg of record.messages) {
|
|
159
|
+
const role = classifyRole(msg);
|
|
160
|
+
if (!role) continue;
|
|
161
|
+
|
|
162
|
+
const stamp = msg.timestamp || msg.createTime;
|
|
163
|
+
if (!stamp) continue;
|
|
164
|
+
const ts = new Date(stamp);
|
|
165
|
+
if (isNaN(ts.getTime())) continue;
|
|
166
|
+
|
|
167
|
+
sessionEvents.push({
|
|
168
|
+
sessionId: filePath,
|
|
169
|
+
source: 'gemini-cli',
|
|
170
|
+
project,
|
|
171
|
+
timestamp: ts,
|
|
172
|
+
role,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
if (role !== 'assistant') continue;
|
|
176
|
+
const tokens = extractTokens(msg);
|
|
177
|
+
if (!tokens) continue;
|
|
178
|
+
|
|
179
|
+
entries.push({
|
|
180
|
+
source: 'gemini-cli',
|
|
181
|
+
model: msg.model || 'unknown',
|
|
182
|
+
project,
|
|
183
|
+
timestamp: ts,
|
|
184
|
+
...tokens,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
|
|
190
|
+
}
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { createReadStream, existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { findGrokDataDirs, getGrokSessionsDir } from '../tools.js';
|
|
5
|
+
import { grokSessionsDir, normalizeExtraRoot } from '../extra-roots.js';
|
|
6
|
+
import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
7
|
+
import { readJsonSafe, projectFromPath } from './fs-utils.js';
|
|
8
|
+
|
|
9
|
+
const SOURCE = 'grok';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Grok (Grok Build TUI / CLI) parser.
|
|
13
|
+
*
|
|
14
|
+
* Layout (see ~/.grok/docs/user-guide/17-sessions.md):
|
|
15
|
+
* $GROK_HOME/sessions/<url-encoded-cwd>/<session-id>/
|
|
16
|
+
* summary.json — cwd, model, timestamps
|
|
17
|
+
* updates.jsonl — ACP session updates; turn_completed carries exact usage
|
|
18
|
+
* events.jsonl — turn_started / turn_ended timing
|
|
19
|
+
*
|
|
20
|
+
* GROK_HOME defaults to ~/.grok. Override with GROK_HOME or
|
|
21
|
+
* VIBE_USAGE_GROK_SESSIONS (tests / relocated session trees).
|
|
22
|
+
*
|
|
23
|
+
* Token usage comes from updates.jsonl `turn_completed.usage` (and per-model
|
|
24
|
+
* `modelUsage` when present). inputTokens is non-cached prompt (total − cache
|
|
25
|
+
* reads), matching Codex/Copilot so totalTokens does not double-count cache.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Decode a sessions group dirname; fall back to basename after decode. */
|
|
29
|
+
function projectFromGroupDir(groupName, groupPath, strict = false) {
|
|
30
|
+
const cwdFile = join(groupPath, '.cwd');
|
|
31
|
+
if (existsSync(cwdFile)) {
|
|
32
|
+
try {
|
|
33
|
+
const raw = readFileSync(cwdFile, 'utf-8').trim();
|
|
34
|
+
if (raw) return projectFromPath(raw);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
if (strict) throw err;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const decoded = decodeURIComponent(groupName);
|
|
41
|
+
if (decoded.includes('/') || decoded.includes('\\')) {
|
|
42
|
+
return projectFromPath(decoded);
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// not URI-encoded
|
|
46
|
+
}
|
|
47
|
+
return groupName || 'unknown';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function toDate(value) {
|
|
51
|
+
if (value == null) return null;
|
|
52
|
+
if (value instanceof Date) {
|
|
53
|
+
return Number.isNaN(value.getTime()) ? null : value;
|
|
54
|
+
}
|
|
55
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
56
|
+
// Unix seconds (Grok updates.jsonl) vs milliseconds
|
|
57
|
+
const ms = value < 1e12 ? value * 1000 : value;
|
|
58
|
+
const d = new Date(ms);
|
|
59
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
60
|
+
}
|
|
61
|
+
if (typeof value === 'string' && value.trim()) {
|
|
62
|
+
const d = new Date(value);
|
|
63
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function pushUsageEntry(entries, { model, project, timestamp, usage }) {
|
|
69
|
+
if (!usage || typeof usage !== 'object') return;
|
|
70
|
+
if (!timestamp) return;
|
|
71
|
+
|
|
72
|
+
const totalInput = Math.max(0, Number(usage.inputTokens) || 0);
|
|
73
|
+
const cached = Math.max(0, Number(usage.cachedReadTokens) || 0);
|
|
74
|
+
const output = Math.max(0, Number(usage.outputTokens) || 0);
|
|
75
|
+
const reasoning = Math.max(0, Number(usage.reasoningTokens) || 0);
|
|
76
|
+
|
|
77
|
+
// Prefer exclusive fields when both are present (Codex-style).
|
|
78
|
+
const inputTokens = Math.max(0, totalInput - cached);
|
|
79
|
+
const outputTokens = Math.max(0, output - reasoning);
|
|
80
|
+
|
|
81
|
+
if (inputTokens + outputTokens + cached + reasoning === 0) return;
|
|
82
|
+
|
|
83
|
+
entries.push({
|
|
84
|
+
source: SOURCE,
|
|
85
|
+
model: model || 'unknown',
|
|
86
|
+
project,
|
|
87
|
+
timestamp,
|
|
88
|
+
inputTokens,
|
|
89
|
+
outputTokens,
|
|
90
|
+
cachedInputTokens: cached,
|
|
91
|
+
reasoningOutputTokens: reasoning,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function emitTurnUsage(entries, { usage, project, timestamp, fallbackModel }) {
|
|
96
|
+
if (!usage || typeof usage !== 'object') return;
|
|
97
|
+
|
|
98
|
+
const modelUsage = usage.modelUsage;
|
|
99
|
+
if (modelUsage && typeof modelUsage === 'object' && Object.keys(modelUsage).length > 0) {
|
|
100
|
+
for (const [model, mUsage] of Object.entries(modelUsage)) {
|
|
101
|
+
pushUsageEntry(entries, {
|
|
102
|
+
model,
|
|
103
|
+
project,
|
|
104
|
+
timestamp,
|
|
105
|
+
usage: mUsage && typeof mUsage === 'object' ? mUsage : usage,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
pushUsageEntry(entries, {
|
|
112
|
+
model: fallbackModel,
|
|
113
|
+
project,
|
|
114
|
+
timestamp,
|
|
115
|
+
usage,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function forEachJsonlLine(filePath, onLine, strict = false) {
|
|
120
|
+
if (!existsSync(filePath)) return;
|
|
121
|
+
let stream;
|
|
122
|
+
try {
|
|
123
|
+
stream = createReadStream(filePath, { encoding: 'utf-8' });
|
|
124
|
+
} catch (err) {
|
|
125
|
+
if (strict) throw err;
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
130
|
+
try {
|
|
131
|
+
for await (const line of rl) {
|
|
132
|
+
const trimmed = line.trim();
|
|
133
|
+
if (!trimmed) continue;
|
|
134
|
+
let obj;
|
|
135
|
+
try {
|
|
136
|
+
obj = JSON.parse(trimmed);
|
|
137
|
+
} catch {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
onLine(obj);
|
|
141
|
+
}
|
|
142
|
+
} catch (err) {
|
|
143
|
+
if (strict) throw err;
|
|
144
|
+
// unreadable / truncated mid-write — keep what we have
|
|
145
|
+
} finally {
|
|
146
|
+
rl.close();
|
|
147
|
+
stream.destroy();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function listSessionDirs(sessionsDir, strict = false) {
|
|
152
|
+
const results = [];
|
|
153
|
+
if (!existsSync(sessionsDir)) {
|
|
154
|
+
if (strict) throw new Error(`missing sessions directory: ${sessionsDir}`);
|
|
155
|
+
return results;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let groups;
|
|
159
|
+
try {
|
|
160
|
+
groups = readdirSync(sessionsDir, { withFileTypes: true });
|
|
161
|
+
} catch (err) {
|
|
162
|
+
if (strict) throw err;
|
|
163
|
+
return results;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
for (const group of groups) {
|
|
167
|
+
if (!group.isDirectory()) continue;
|
|
168
|
+
// Skip non-project group dirs (e.g. future index folders).
|
|
169
|
+
const groupPath = join(sessionsDir, group.name);
|
|
170
|
+
let children;
|
|
171
|
+
try {
|
|
172
|
+
children = readdirSync(groupPath, { withFileTypes: true });
|
|
173
|
+
} catch (err) {
|
|
174
|
+
if (strict) throw err;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const projectFallback = projectFromGroupDir(group.name, groupPath, strict);
|
|
179
|
+
|
|
180
|
+
for (const child of children) {
|
|
181
|
+
if (!child.isDirectory()) continue;
|
|
182
|
+
const sessionPath = join(groupPath, child.name);
|
|
183
|
+
// A real session always has summary.json (or at least updates/chat history).
|
|
184
|
+
if (
|
|
185
|
+
!existsSync(join(sessionPath, 'summary.json')) &&
|
|
186
|
+
!existsSync(join(sessionPath, 'updates.jsonl'))
|
|
187
|
+
) {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
results.push({
|
|
191
|
+
sessionId: child.name,
|
|
192
|
+
sessionPath,
|
|
193
|
+
projectFallback,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return results;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Parse all Grok sessions under the configured sessions root(s).
|
|
203
|
+
* @returns {Promise<{ buckets: object[], sessions: object[] }>}
|
|
204
|
+
*/
|
|
205
|
+
export async function parse({ extraRoots = [] } = {}) {
|
|
206
|
+
if (!process.env.VIBE_USAGE_GROK_SESSIONS?.trim()) {
|
|
207
|
+
for (const root of extraRoots) {
|
|
208
|
+
const sessionsDir = grokSessionsDir(root);
|
|
209
|
+
try {
|
|
210
|
+
if (!statSync(sessionsDir).isDirectory()) throw new Error('not a directory');
|
|
211
|
+
} catch {
|
|
212
|
+
return {
|
|
213
|
+
buckets: [],
|
|
214
|
+
sessions: [],
|
|
215
|
+
skipped: true,
|
|
216
|
+
warnings: [`grok: 额外根目录不可用,已跳过本次 Grok 同步: ${normalizeExtraRoot(root)}`],
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const strictRoots = process.env.VIBE_USAGE_GROK_SESSIONS?.trim()
|
|
222
|
+
? new Set()
|
|
223
|
+
: new Set(extraRoots.map(grokSessionsDir));
|
|
224
|
+
const sessionRoots = findGrokDataDirs(extraRoots);
|
|
225
|
+
for (const configuredRoot of strictRoots) {
|
|
226
|
+
if (!sessionRoots.includes(configuredRoot)) sessionRoots.push(configuredRoot);
|
|
227
|
+
}
|
|
228
|
+
// findGrokDataDirs returns sessions dirs; also allow empty → try default once
|
|
229
|
+
const roots = sessionRoots.length > 0 ? sessionRoots : [getGrokSessionsDir()].filter(existsSync);
|
|
230
|
+
if (roots.length === 0) return { buckets: [], sessions: [] };
|
|
231
|
+
|
|
232
|
+
const entries = [];
|
|
233
|
+
const sessionEvents = [];
|
|
234
|
+
|
|
235
|
+
const candidates = [];
|
|
236
|
+
for (const sessionsDir of roots) {
|
|
237
|
+
const strict = strictRoots.has(sessionsDir);
|
|
238
|
+
try {
|
|
239
|
+
for (const session of listSessionDirs(sessionsDir, strict)) {
|
|
240
|
+
candidates.push({ ...session, strict, configuredRoot: sessionsDir });
|
|
241
|
+
}
|
|
242
|
+
} catch {
|
|
243
|
+
return {
|
|
244
|
+
buckets: [], sessions: [], skipped: true,
|
|
245
|
+
warnings: [`grok: 额外根目录读取失败,已保留上次同步数据: ${sessionsDir}`],
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let sessionsToParse = candidates;
|
|
251
|
+
if (roots.length > 1) {
|
|
252
|
+
const selectedSessions = new Map();
|
|
253
|
+
for (const session of candidates) {
|
|
254
|
+
const fileSize = (name) => {
|
|
255
|
+
try {
|
|
256
|
+
return statSync(join(session.sessionPath, name)).size;
|
|
257
|
+
} catch {
|
|
258
|
+
return 0;
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
const score = [fileSize('updates.jsonl'), fileSize('events.jsonl'), fileSize('summary.json')];
|
|
262
|
+
const previous = selectedSessions.get(session.sessionId);
|
|
263
|
+
const moreComplete = !previous || score.some((value, index) => (
|
|
264
|
+
value !== previous.score[index] && value > previous.score[index]
|
|
265
|
+
&& score.slice(0, index).every((prior, priorIndex) => prior === previous.score[priorIndex])
|
|
266
|
+
));
|
|
267
|
+
if (moreComplete) selectedSessions.set(session.sessionId, { ...session, score });
|
|
268
|
+
}
|
|
269
|
+
sessionsToParse = [...selectedSessions.values()];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
for (const {
|
|
273
|
+
sessionId,
|
|
274
|
+
sessionPath,
|
|
275
|
+
projectFallback,
|
|
276
|
+
strict,
|
|
277
|
+
configuredRoot,
|
|
278
|
+
} of sessionsToParse) {
|
|
279
|
+
try {
|
|
280
|
+
const summaryPath = join(sessionPath, 'summary.json');
|
|
281
|
+
let summary;
|
|
282
|
+
if (strict && existsSync(summaryPath)) {
|
|
283
|
+
summary = JSON.parse(readFileSync(summaryPath, 'utf-8'));
|
|
284
|
+
} else {
|
|
285
|
+
summary = readJsonSafe(summaryPath) || {};
|
|
286
|
+
}
|
|
287
|
+
const cwd = summary.info?.cwd || summary.git_root_dir || null;
|
|
288
|
+
const project = cwd ? projectFromPath(cwd) : projectFallback;
|
|
289
|
+
const fallbackModel = summary.current_model_id || 'unknown';
|
|
290
|
+
|
|
291
|
+
// Prefer updates.jsonl turn_completed for exact usage + message timings.
|
|
292
|
+
let sawUserOrAssistant = false;
|
|
293
|
+
await forEachJsonlLine(join(sessionPath, 'updates.jsonl'), (obj) => {
|
|
294
|
+
const update = obj?.params?.update;
|
|
295
|
+
if (!update || typeof update !== 'object') return;
|
|
296
|
+
|
|
297
|
+
const kind = update.sessionUpdate;
|
|
298
|
+
const timestamp = toDate(obj.timestamp);
|
|
299
|
+
|
|
300
|
+
if (kind === 'turn_completed' && timestamp) {
|
|
301
|
+
emitTurnUsage(entries, {
|
|
302
|
+
usage: update.usage,
|
|
303
|
+
project,
|
|
304
|
+
timestamp,
|
|
305
|
+
fallbackModel,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (!timestamp) return;
|
|
310
|
+
|
|
311
|
+
if (kind === 'user_message_chunk') {
|
|
312
|
+
sawUserOrAssistant = true;
|
|
313
|
+
sessionEvents.push({
|
|
314
|
+
sessionId,
|
|
315
|
+
source: SOURCE,
|
|
316
|
+
project,
|
|
317
|
+
timestamp,
|
|
318
|
+
role: 'user',
|
|
319
|
+
});
|
|
320
|
+
} else if (kind === 'agent_message_chunk' || kind === 'turn_completed') {
|
|
321
|
+
sawUserOrAssistant = true;
|
|
322
|
+
sessionEvents.push({
|
|
323
|
+
sessionId,
|
|
324
|
+
source: SOURCE,
|
|
325
|
+
project,
|
|
326
|
+
timestamp,
|
|
327
|
+
role: 'assistant',
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}, strict);
|
|
331
|
+
|
|
332
|
+
// Fallback timing from events.jsonl when updates lack message chunks
|
|
333
|
+
// (short/aborted sessions, older builds).
|
|
334
|
+
if (!sawUserOrAssistant) {
|
|
335
|
+
await forEachJsonlLine(join(sessionPath, 'events.jsonl'), (obj) => {
|
|
336
|
+
const timestamp = toDate(obj.ts || obj.timestamp);
|
|
337
|
+
if (!timestamp) return;
|
|
338
|
+
if (obj.type === 'turn_started') {
|
|
339
|
+
sessionEvents.push({
|
|
340
|
+
sessionId,
|
|
341
|
+
source: SOURCE,
|
|
342
|
+
project,
|
|
343
|
+
timestamp,
|
|
344
|
+
role: 'user',
|
|
345
|
+
});
|
|
346
|
+
} else if (obj.type === 'turn_ended' || obj.type === 'first_token') {
|
|
347
|
+
sessionEvents.push({
|
|
348
|
+
sessionId,
|
|
349
|
+
source: SOURCE,
|
|
350
|
+
project,
|
|
351
|
+
timestamp,
|
|
352
|
+
role: 'assistant',
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
}, strict);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Last-resort session envelope from summary timestamps so a session with
|
|
359
|
+
// no parseable turns still appears once usage lands later.
|
|
360
|
+
if (sessionEvents.every((e) => e.sessionId !== sessionId)) {
|
|
361
|
+
const created = toDate(summary.created_at || summary.info?.created_at);
|
|
362
|
+
const updated = toDate(summary.updated_at || summary.last_active_at);
|
|
363
|
+
if (created) {
|
|
364
|
+
sessionEvents.push({
|
|
365
|
+
sessionId,
|
|
366
|
+
source: SOURCE,
|
|
367
|
+
project,
|
|
368
|
+
timestamp: created,
|
|
369
|
+
role: 'user',
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
if (updated && (!created || updated.getTime() !== created.getTime())) {
|
|
373
|
+
sessionEvents.push({
|
|
374
|
+
sessionId,
|
|
375
|
+
source: SOURCE,
|
|
376
|
+
project,
|
|
377
|
+
timestamp: updated,
|
|
378
|
+
role: 'assistant',
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
} catch (err) {
|
|
383
|
+
if (!strict) throw err;
|
|
384
|
+
return {
|
|
385
|
+
buckets: [], sessions: [], skipped: true,
|
|
386
|
+
warnings: [`grok: 额外根目录读取失败,已保留上次同步数据: ${configuredRoot}`],
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
buckets: aggregateToBuckets(entries),
|
|
393
|
+
sessions: extractSessions(sessionEvents),
|
|
394
|
+
};
|
|
395
|
+
}
|