@pi-unipi/info-screen 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config.ts +171 -0
- package/core-groups.ts +482 -0
- package/index.ts +191 -0
- package/package.json +50 -0
- package/registry.ts +183 -0
- package/settings/settings-tui.ts +287 -0
- package/tui/info-overlay.ts +406 -0
- package/types.ts +73 -0
- package/usage-parser.ts +308 -0
package/usage-parser.ts
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/info-screen — Session file parser
|
|
3
|
+
*
|
|
4
|
+
* Parses ~/.pi/agent/sessions/ JSONL files for usage stats.
|
|
5
|
+
* Reference: tmustier/pi-extensions/usage-extension
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
|
|
9
|
+
import { join, basename } from "node:path";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
|
|
12
|
+
/** Usage data for a single message */
|
|
13
|
+
interface MessageUsage {
|
|
14
|
+
input: number;
|
|
15
|
+
output: number;
|
|
16
|
+
cacheRead: number;
|
|
17
|
+
cacheWrite: number;
|
|
18
|
+
cost: { total: number };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Aggregated usage stats */
|
|
22
|
+
export interface UsageStats {
|
|
23
|
+
/** Total tokens by period */
|
|
24
|
+
tokens: {
|
|
25
|
+
today: number;
|
|
26
|
+
week: number;
|
|
27
|
+
month: number;
|
|
28
|
+
allTime: number;
|
|
29
|
+
};
|
|
30
|
+
/** Total cost by period (USD) */
|
|
31
|
+
cost: {
|
|
32
|
+
today: number;
|
|
33
|
+
week: number;
|
|
34
|
+
month: number;
|
|
35
|
+
allTime: number;
|
|
36
|
+
};
|
|
37
|
+
/** Token counts by model (all time) */
|
|
38
|
+
byModel: Record<string, { tokens: number; cost: number; sessions: number }>;
|
|
39
|
+
/** Token counts by model (today) */
|
|
40
|
+
byModelToday: Record<string, { tokens: number; cost: number; sessions: number }>;
|
|
41
|
+
/** Token counts by model (this week) */
|
|
42
|
+
byModelWeek: Record<string, { tokens: number; cost: number; sessions: number }>;
|
|
43
|
+
/** Token counts by model (this month) */
|
|
44
|
+
byModelMonth: Record<string, { tokens: number; cost: number; sessions: number }>;
|
|
45
|
+
/** Total sessions */
|
|
46
|
+
sessionCount: number;
|
|
47
|
+
/** Total messages */
|
|
48
|
+
messageCount: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Time period boundaries */
|
|
52
|
+
interface PeriodBounds {
|
|
53
|
+
start: Date;
|
|
54
|
+
end: Date;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get the sessions directory path.
|
|
59
|
+
*/
|
|
60
|
+
function getSessionsDir(): string {
|
|
61
|
+
// Replicate Pi's logic: respect PI_CODING_AGENT_DIR env var
|
|
62
|
+
const agentDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
|
|
63
|
+
return join(agentDir, "sessions");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get period boundaries for today, this week, this month.
|
|
68
|
+
* Today starts at 00:00 local time.
|
|
69
|
+
* Week starts on Monday.
|
|
70
|
+
*/
|
|
71
|
+
function getPeriodBounds(): { today: PeriodBounds; week: PeriodBounds; month: PeriodBounds } {
|
|
72
|
+
const now = new Date();
|
|
73
|
+
|
|
74
|
+
// Start of today (midnight local time)
|
|
75
|
+
const todayStart = new Date(now);
|
|
76
|
+
todayStart.setHours(0, 0, 0, 0);
|
|
77
|
+
|
|
78
|
+
// Start of current week (Monday 00:00)
|
|
79
|
+
const weekStart = new Date(now);
|
|
80
|
+
const dayOfWeek = weekStart.getDay(); // 0 = Sunday, 1 = Monday, ...
|
|
81
|
+
const daysSinceMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
|
82
|
+
weekStart.setDate(weekStart.getDate() - daysSinceMonday);
|
|
83
|
+
weekStart.setHours(0, 0, 0, 0);
|
|
84
|
+
|
|
85
|
+
// Start of current month (1st day 00:00)
|
|
86
|
+
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
today: { start: todayStart, end: now },
|
|
90
|
+
week: { start: weekStart, end: now },
|
|
91
|
+
month: { start: monthStart, end: now },
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Parse a JSONL session file and extract usage data.
|
|
99
|
+
* Matches tmustier's parsing logic.
|
|
100
|
+
*/
|
|
101
|
+
function parseSessionFile(
|
|
102
|
+
filePath: string,
|
|
103
|
+
seenHashes: Set<string>
|
|
104
|
+
): Array<{ usage: MessageUsage; model: string; timestamp: number }> {
|
|
105
|
+
const results: Array<{ usage: MessageUsage; model: string; timestamp: number }> = [];
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const content = readFileSync(filePath, "utf-8");
|
|
109
|
+
const lines = content.trim().split("\n");
|
|
110
|
+
|
|
111
|
+
for (let i = 0; i < lines.length; i++) {
|
|
112
|
+
const line = lines[i];
|
|
113
|
+
if (!line.trim()) continue;
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
const entry = JSON.parse(line);
|
|
117
|
+
|
|
118
|
+
// Match tmustier's parsing: check entry.type === "message" and entry.message?.role === "assistant"
|
|
119
|
+
if (entry.type === "message" && entry.message?.role === "assistant") {
|
|
120
|
+
const msg = entry.message;
|
|
121
|
+
if (msg.usage && msg.provider && msg.model) {
|
|
122
|
+
const input = msg.usage.input || 0;
|
|
123
|
+
const output = msg.usage.output || 0;
|
|
124
|
+
const cacheRead = msg.usage.cacheRead || 0;
|
|
125
|
+
const cacheWrite = msg.usage.cacheWrite || 0;
|
|
126
|
+
const cost = msg.usage.cost?.total || 0;
|
|
127
|
+
|
|
128
|
+
// Get timestamp
|
|
129
|
+
const fallbackTs = entry.timestamp ? new Date(entry.timestamp).getTime() : 0;
|
|
130
|
+
const timestamp = msg.timestamp || (Number.isNaN(fallbackTs) ? 0 : fallbackTs);
|
|
131
|
+
|
|
132
|
+
// Deduplicate copied history across branched session files
|
|
133
|
+
const totalTokens = input + output + cacheRead + cacheWrite;
|
|
134
|
+
const hash = `${timestamp}:${totalTokens}`;
|
|
135
|
+
if (seenHashes.has(hash)) continue;
|
|
136
|
+
seenHashes.add(hash);
|
|
137
|
+
|
|
138
|
+
// Only include if we have valid data
|
|
139
|
+
if (input > 0 || output > 0 || cost > 0) {
|
|
140
|
+
results.push({
|
|
141
|
+
usage: {
|
|
142
|
+
input,
|
|
143
|
+
output,
|
|
144
|
+
cacheRead,
|
|
145
|
+
cacheWrite,
|
|
146
|
+
cost: { total: cost },
|
|
147
|
+
},
|
|
148
|
+
model: msg.model,
|
|
149
|
+
timestamp,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
// Skip malformed lines
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
} catch {
|
|
159
|
+
// Skip unreadable files
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return results;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Collect all session files recursively.
|
|
167
|
+
*/
|
|
168
|
+
function collectSessionFiles(dir: string, files: string[]): void {
|
|
169
|
+
try {
|
|
170
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
171
|
+
for (const entry of entries) {
|
|
172
|
+
const entryPath = join(dir, entry.name);
|
|
173
|
+
if (entry.isDirectory()) {
|
|
174
|
+
collectSessionFiles(entryPath, files);
|
|
175
|
+
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
176
|
+
files.push(entryPath);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
} catch {
|
|
180
|
+
// Skip directories we can't read
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Parse all session files and aggregate usage stats.
|
|
186
|
+
* Matches tmustier's parsing logic.
|
|
187
|
+
*/
|
|
188
|
+
export function parseUsageStats(): UsageStats {
|
|
189
|
+
const sessionsDir = getSessionsDir();
|
|
190
|
+
const stats: UsageStats = {
|
|
191
|
+
tokens: { today: 0, week: 0, month: 0, allTime: 0 },
|
|
192
|
+
cost: { today: 0, week: 0, month: 0, allTime: 0 },
|
|
193
|
+
byModel: {},
|
|
194
|
+
byModelToday: {},
|
|
195
|
+
byModelWeek: {},
|
|
196
|
+
byModelMonth: {},
|
|
197
|
+
sessionCount: 0,
|
|
198
|
+
messageCount: 0,
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
if (!existsSync(sessionsDir)) return stats;
|
|
202
|
+
|
|
203
|
+
const periods = getPeriodBounds();
|
|
204
|
+
const seenHashes = new Set<string>();
|
|
205
|
+
|
|
206
|
+
// Collect all session files recursively
|
|
207
|
+
const sessionFiles: string[] = [];
|
|
208
|
+
collectSessionFiles(sessionsDir, sessionFiles);
|
|
209
|
+
sessionFiles.sort();
|
|
210
|
+
|
|
211
|
+
for (const filePath of sessionFiles) {
|
|
212
|
+
const messages = parseSessionFile(filePath, seenHashes);
|
|
213
|
+
|
|
214
|
+
if (messages.length === 0) continue;
|
|
215
|
+
|
|
216
|
+
stats.sessionCount++;
|
|
217
|
+
stats.messageCount += messages.length;
|
|
218
|
+
|
|
219
|
+
for (const msg of messages) {
|
|
220
|
+
// Match tmustier's token calculation: input + output + cacheWrite (not cacheRead)
|
|
221
|
+
const totalTokens = msg.usage.input + msg.usage.output + msg.usage.cacheWrite;
|
|
222
|
+
|
|
223
|
+
// All time
|
|
224
|
+
stats.tokens.allTime += totalTokens;
|
|
225
|
+
stats.cost.allTime += msg.usage.cost.total;
|
|
226
|
+
|
|
227
|
+
// Today
|
|
228
|
+
if (msg.timestamp >= periods.today.start.getTime()) {
|
|
229
|
+
stats.tokens.today += totalTokens;
|
|
230
|
+
stats.cost.today += msg.usage.cost.total;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// This week
|
|
234
|
+
if (msg.timestamp >= periods.week.start.getTime()) {
|
|
235
|
+
stats.tokens.week += totalTokens;
|
|
236
|
+
stats.cost.week += msg.usage.cost.total;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// This month
|
|
240
|
+
if (msg.timestamp >= periods.month.start.getTime()) {
|
|
241
|
+
stats.tokens.month += totalTokens;
|
|
242
|
+
stats.cost.month += msg.usage.cost.total;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// By model (all time)
|
|
246
|
+
const model = msg.model;
|
|
247
|
+
if (!stats.byModel[model]) {
|
|
248
|
+
stats.byModel[model] = { tokens: 0, cost: 0, sessions: 0 };
|
|
249
|
+
}
|
|
250
|
+
stats.byModel[model].tokens += totalTokens;
|
|
251
|
+
stats.byModel[model].cost += msg.usage.cost.total;
|
|
252
|
+
stats.byModel[model].sessions++;
|
|
253
|
+
|
|
254
|
+
// By model (today)
|
|
255
|
+
if (msg.timestamp >= periods.today.start.getTime()) {
|
|
256
|
+
if (!stats.byModelToday[model]) {
|
|
257
|
+
stats.byModelToday[model] = { tokens: 0, cost: 0, sessions: 0 };
|
|
258
|
+
}
|
|
259
|
+
stats.byModelToday[model].tokens += totalTokens;
|
|
260
|
+
stats.byModelToday[model].cost += msg.usage.cost.total;
|
|
261
|
+
stats.byModelToday[model].sessions++;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// By model (this week)
|
|
265
|
+
if (msg.timestamp >= periods.week.start.getTime()) {
|
|
266
|
+
if (!stats.byModelWeek[model]) {
|
|
267
|
+
stats.byModelWeek[model] = { tokens: 0, cost: 0, sessions: 0 };
|
|
268
|
+
}
|
|
269
|
+
stats.byModelWeek[model].tokens += totalTokens;
|
|
270
|
+
stats.byModelWeek[model].cost += msg.usage.cost.total;
|
|
271
|
+
stats.byModelWeek[model].sessions++;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// By model (this month)
|
|
275
|
+
if (msg.timestamp >= periods.month.start.getTime()) {
|
|
276
|
+
if (!stats.byModelMonth[model]) {
|
|
277
|
+
stats.byModelMonth[model] = { tokens: 0, cost: 0, sessions: 0 };
|
|
278
|
+
}
|
|
279
|
+
stats.byModelMonth[model].tokens += totalTokens;
|
|
280
|
+
stats.byModelMonth[model].cost += msg.usage.cost.total;
|
|
281
|
+
stats.byModelMonth[model].sessions++;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return stats;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Format token count for display.
|
|
291
|
+
*/
|
|
292
|
+
export function formatTokens(n: number): string {
|
|
293
|
+
if (n < 1000) return n.toString();
|
|
294
|
+
if (n < 10000) return `${(n / 1000).toFixed(1)}k`;
|
|
295
|
+
if (n < 1000000) return `${Math.round(n / 1000)}k`;
|
|
296
|
+
if (n < 10000000) return `${(n / 1000000).toFixed(1)}M`;
|
|
297
|
+
return `${Math.round(n / 1000000)}M`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Format cost for display.
|
|
302
|
+
*/
|
|
303
|
+
export function formatCost(n: number): string {
|
|
304
|
+
if (n === 0) return "$0.00";
|
|
305
|
+
if (n < 0.01) return "<$0.01";
|
|
306
|
+
if (n < 1) return `$${n.toFixed(2)}`;
|
|
307
|
+
return `$${n.toFixed(2)}`;
|
|
308
|
+
}
|