@pify/usage 0.1.0 → 0.2.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/README.md +4 -0
- package/package.json +1 -1
- package/src/aggregate.ts +7 -1
- package/src/format.ts +19 -11
- package/src/sessions.ts +61 -18
- package/src/types.ts +3 -0
package/README.md
CHANGED
|
@@ -22,9 +22,13 @@ History (214 local session files)
|
|
|
22
22
|
By model (all time)
|
|
23
23
|
anthropic/claude-fable-5 $12.30 · 4.1M tok
|
|
24
24
|
openai/gpt-5.5 $9.10 · 5.7M tok
|
|
25
|
+
By project (all time)
|
|
26
|
+
D--project-pify-plugins $14.80 · 6.2M tok
|
|
27
|
+
D--project-shop-api $6.60 · 3.6M tok
|
|
25
28
|
```
|
|
26
29
|
|
|
27
30
|
- **History done right** (tmustier's lessons): counts every usage-bearing entry in pi's session JSONL — assistant turns plus the tool-result/compaction usage pi 0.81+ persists; negative/NaN fields clamp to zero; days are your local calendar days; a per-file mtime cache keeps repeat scans instant.
|
|
31
|
+
- **Per-project spend** (v0.2): pi stores sessions one directory per project, so the dashboard can show where the money actually went — the top 5 projects by cost, all time.
|
|
28
32
|
- **`usage_status` tool**: the agent can check session + today totals before committing to expensive work (subagent fan-outs, large reads).
|
|
29
33
|
|
|
30
34
|
Provider quota APIs (Codex windows, Copilot allowances, OpenRouter credits…) are deliberately out of v0.1 — they cost ~18k lines of per-provider contract maintenance (see `@narumitw/pi-usage` if you need them today).
|
package/package.json
CHANGED
package/src/aggregate.ts
CHANGED
|
@@ -32,6 +32,7 @@ export function dayKey(timestamp: number): string {
|
|
|
32
32
|
export function aggregate(records: UsageRecord[], files: number): HistoryAggregate {
|
|
33
33
|
const byDay = new Map<string, UsageTotals>();
|
|
34
34
|
const byModel = new Map<string, UsageTotals>();
|
|
35
|
+
const byProject = new Map<string, UsageTotals>();
|
|
35
36
|
const total = emptyTotals();
|
|
36
37
|
|
|
37
38
|
for (const record of records) {
|
|
@@ -40,9 +41,13 @@ export function aggregate(records: UsageRecord[], files: number): HistoryAggrega
|
|
|
40
41
|
addRecord(byDay.get(day) ?? byDay.set(day, emptyTotals()).get(day)!, record);
|
|
41
42
|
const model = `${record.provider}/${record.model}`;
|
|
42
43
|
addRecord(byModel.get(model) ?? byModel.set(model, emptyTotals()).get(model)!, record);
|
|
44
|
+
if (record.project) {
|
|
45
|
+
const p = record.project;
|
|
46
|
+
addRecord(byProject.get(p) ?? byProject.set(p, emptyTotals()).get(p)!, record);
|
|
47
|
+
}
|
|
43
48
|
}
|
|
44
49
|
|
|
45
|
-
return { byDay, byModel, total, files };
|
|
50
|
+
return { byDay, byModel, byProject, total, files };
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
/** Sum totals for days within the trailing window (inclusive of today). */
|
|
@@ -91,6 +96,7 @@ export function recordFromEntry(entry: unknown): UsageRecord | null {
|
|
|
91
96
|
timestamp: Number.isFinite(ts) ? ts : 0,
|
|
92
97
|
model: typeof message.model === "string" ? message.model : "unknown",
|
|
93
98
|
provider: typeof message.provider === "string" ? message.provider : "unknown",
|
|
99
|
+
project: "",
|
|
94
100
|
input: finite(usage.input),
|
|
95
101
|
output: finite(usage.output),
|
|
96
102
|
cacheRead: finite(usage.cacheRead),
|
package/src/format.ts
CHANGED
|
@@ -43,16 +43,24 @@ export function historyBlock(history: HistoryAggregate, now: number): string {
|
|
|
43
43
|
` 30 days ${formatCost(month.cost)} · ${formatTokens(month.totalTokens)} tok`,
|
|
44
44
|
];
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
.slice(0, 6);
|
|
49
|
-
if (models.length > 0) {
|
|
50
|
-
lines.push("By model (all time)");
|
|
51
|
-
const width = models.reduce((m, [name]) => Math.max(m, Math.min(name.length, 40)), 0);
|
|
52
|
-
for (const [name, totals] of models) {
|
|
53
|
-
const label = name.length > 40 ? `${name.slice(0, 39)}…` : name;
|
|
54
|
-
lines.push(` ${label.padEnd(width)} ${formatCost(totals.cost)} · ${formatTokens(totals.totalTokens)} tok`);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
46
|
+
pushBreakdown(lines, "By model (all time)", history.byModel, 6);
|
|
47
|
+
pushBreakdown(lines, "By project (all time)", history.byProject, 5);
|
|
57
48
|
return lines.join("\n");
|
|
58
49
|
}
|
|
50
|
+
|
|
51
|
+
/** One "name $cost · N tok" table, biggest spend first. */
|
|
52
|
+
function pushBreakdown(
|
|
53
|
+
lines: string[],
|
|
54
|
+
heading: string,
|
|
55
|
+
totals: Map<string, UsageTotals>,
|
|
56
|
+
limit: number,
|
|
57
|
+
): void {
|
|
58
|
+
const rows = [...totals.entries()].sort((a, b) => b[1].cost - a[1].cost).slice(0, limit);
|
|
59
|
+
if (rows.length === 0) return;
|
|
60
|
+
lines.push(heading);
|
|
61
|
+
const width = rows.reduce((m, [name]) => Math.max(m, Math.min(name.length, 40)), 0);
|
|
62
|
+
for (const [name, t] of rows) {
|
|
63
|
+
const label = name.length > 40 ? `${name.slice(0, 39)}…` : name;
|
|
64
|
+
lines.push(` ${label.padEnd(width)} ${formatCost(t.cost)} · ${formatTokens(t.totalTokens)} tok`);
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/sessions.ts
CHANGED
|
@@ -44,27 +44,70 @@ export interface ScanResult {
|
|
|
44
44
|
files: number;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
47
|
+
const MAX_LABEL = 34;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Readable name for a project directory. pi encodes the project path into the
|
|
51
|
+
* directory name (--D--project-pify-plugins--); the tail is the recognizable
|
|
52
|
+
* part, so long names keep their end.
|
|
53
|
+
*/
|
|
54
|
+
export function projectLabel(dirName: string): string {
|
|
55
|
+
const stripped = dirName.replace(/^-+|-+$/g, "");
|
|
56
|
+
if (!stripped) return "unknown";
|
|
57
|
+
return stripped.length > MAX_LABEL ? `…${stripped.slice(-(MAX_LABEL - 1))}` : stripped;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Session files grouped by the project directory they sit under. */
|
|
61
|
+
function groupByProject(sessionsDir: string): Array<{ project: string; files: string[] }> {
|
|
62
|
+
let names: string[];
|
|
63
|
+
try {
|
|
64
|
+
names = readdirSync(sessionsDir);
|
|
65
|
+
} catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
const groups: Array<{ project: string; files: string[] }> = [];
|
|
69
|
+
const loose: string[] = [];
|
|
70
|
+
for (const name of names) {
|
|
71
|
+
const full = join(sessionsDir, name);
|
|
51
72
|
try {
|
|
52
|
-
const stat = statSync(
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
const fileRecords: UsageRecord[] = [];
|
|
59
|
-
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
60
|
-
const record = recordFromLine(line);
|
|
61
|
-
if (record) fileRecords.push(record);
|
|
73
|
+
const stat = statSync(full);
|
|
74
|
+
if (stat.isDirectory()) {
|
|
75
|
+
groups.push({ project: projectLabel(name), files: listJsonlFiles(full, 1) });
|
|
76
|
+
} else if (name.endsWith(".jsonl") && stat.size <= MAX_FILE_BYTES) {
|
|
77
|
+
loose.push(full);
|
|
62
78
|
}
|
|
63
|
-
cache.set(file, { mtimeMs: stat.mtimeMs, size: stat.size, records: fileRecords });
|
|
64
|
-
records.push(...fileRecords);
|
|
65
79
|
} catch {
|
|
66
|
-
//
|
|
80
|
+
// race/permission — skip
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (loose.length > 0) groups.push({ project: "", files: loose });
|
|
84
|
+
return groups;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function scanSessions(sessionsDir: string): ScanResult {
|
|
88
|
+
const records: UsageRecord[] = [];
|
|
89
|
+
let fileCount = 0;
|
|
90
|
+
for (const { project, files } of groupByProject(sessionsDir)) {
|
|
91
|
+
fileCount += files.length;
|
|
92
|
+
for (const file of files) {
|
|
93
|
+
try {
|
|
94
|
+
const stat = statSync(file);
|
|
95
|
+
const cached = cache.get(file);
|
|
96
|
+
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
|
97
|
+
records.push(...cached.records);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const fileRecords: UsageRecord[] = [];
|
|
101
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
102
|
+
const record = recordFromLine(line);
|
|
103
|
+
if (record) fileRecords.push({ ...record, project });
|
|
104
|
+
}
|
|
105
|
+
cache.set(file, { mtimeMs: stat.mtimeMs, size: stat.size, records: fileRecords });
|
|
106
|
+
records.push(...fileRecords);
|
|
107
|
+
} catch {
|
|
108
|
+
// unreadable — skip
|
|
109
|
+
}
|
|
67
110
|
}
|
|
68
111
|
}
|
|
69
|
-
return { records, files:
|
|
112
|
+
return { records, files: fileCount };
|
|
70
113
|
}
|
package/src/types.ts
CHANGED
|
@@ -23,6 +23,8 @@ export interface UsageRecord {
|
|
|
23
23
|
timestamp: number;
|
|
24
24
|
model: string;
|
|
25
25
|
provider: string;
|
|
26
|
+
/** Project the session belongs to; "" when unknown (live events). */
|
|
27
|
+
project: string;
|
|
26
28
|
input: number;
|
|
27
29
|
output: number;
|
|
28
30
|
cacheRead: number;
|
|
@@ -34,6 +36,7 @@ export interface UsageRecord {
|
|
|
34
36
|
export interface HistoryAggregate {
|
|
35
37
|
byDay: Map<string, UsageTotals>;
|
|
36
38
|
byModel: Map<string, UsageTotals>;
|
|
39
|
+
byProject: Map<string, UsageTotals>;
|
|
37
40
|
total: UsageTotals;
|
|
38
41
|
files: number;
|
|
39
42
|
}
|