@hank-warren/pi-stats 0.2.0 → 0.3.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 +10 -0
- package/cache.ts +54 -6
- package/package.json +1 -1
- package/stats.ts +55 -3
- package/types.ts +4 -2
package/README.md
CHANGED
|
@@ -33,6 +33,16 @@ Pi writes usage to assistant messages, usage-bearing tool results, compactions,
|
|
|
33
33
|
|
|
34
34
|
Persisted subagent files in the conventional `run-*/session.jsonl` layout are included. `pi-processes` does not create model sessions; model usage around its tool calls already belongs to the parent Pi session and therefore needs no separate integration.
|
|
35
35
|
|
|
36
|
+
### Extension usage sidecars
|
|
37
|
+
|
|
38
|
+
Some extensions call a model outside the agent loop, so their usage never reaches a session transcript. `pi-stats` also reads content-free usage sidecars at `<agent dir>/<extension>/usage.jsonl` (plus one rotated `usage.jsonl.1`). Each line is one call:
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{"v":1,"id":"3f2b…","ts":"2026-08-11T22:41:03.118Z","source":"auto-permissions","label":"guardian","provider":"anthropic","model":"claude-fable-5","usage":{"input":812,"output":96,"cacheRead":18442,"cacheWrite":0,"reasoning":48,"cost":0.0121}}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Records are de-duplicated by `id`, counted in every total, and shown as their own `provider/model (label)` row so the overhead stays visible. They never create sessions, so session counts, streaks, and session spans are unaffected. `@hank-warren/pi-auto-permissions` writes one for guardian reviews; set `PI_STATS_DISABLE_USAGE_SIDECARS=1` to ignore all of them.
|
|
45
|
+
|
|
36
46
|
The standard Pi session root, the current external session directory, `PI_CODING_AGENT_SESSION_DIR`, and a configured `subagents.defaultSessionDir` are discovered automatically. Add unusual one-off roots with the platform-delimited `PI_STATS_SESSION_DIRS` environment variable.
|
|
37
47
|
|
|
38
48
|
## Cache and privacy
|
package/cache.ts
CHANGED
|
@@ -2,11 +2,13 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { readFile, readdir, realpath, rename, stat, writeFile, mkdir, chmod, unlink } from "node:fs/promises";
|
|
3
3
|
import { basename, delimiter, dirname, join, resolve } from "node:path";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
-
import { makeIndex, parseSessionText } from "./stats.ts";
|
|
5
|
+
import { makeIndex, parseSessionText, parseUsageSidecar } from "./stats.ts";
|
|
6
6
|
import type { CachedFileRecord, ScanDiagnostics, SessionRecord, StatsCacheFile, StatsIndex, UsageRecord } from "./types.ts";
|
|
7
7
|
|
|
8
|
-
const CACHE_VERSION =
|
|
8
|
+
const CACHE_VERSION = 2 as const;
|
|
9
9
|
const SKIP_DIRECTORIES = new Set(["subagent-artifacts"]);
|
|
10
|
+
/** Extensions record out-of-transcript model usage in <agentDir>/<extension>/usage.jsonl. */
|
|
11
|
+
const USAGE_SIDECAR_NAMES = new Set(["usage.jsonl", "usage.jsonl.1"]);
|
|
10
12
|
|
|
11
13
|
export interface ScanOptions {
|
|
12
14
|
agentDir: string;
|
|
@@ -93,6 +95,37 @@ async function discoverJsonlFiles(roots: readonly string[], signal?: AbortSignal
|
|
|
93
95
|
return [...found].sort();
|
|
94
96
|
}
|
|
95
97
|
|
|
98
|
+
/** One level below the agent directory only; sidecars are a flat, documented convention. */
|
|
99
|
+
async function discoverUsageSidecars(agentDir: string, env: NodeJS.ProcessEnv, signal?: AbortSignal): Promise<string[]> {
|
|
100
|
+
if (env.PI_STATS_DISABLE_USAGE_SIDECARS === "1") return [];
|
|
101
|
+
const found = new Set<string>();
|
|
102
|
+
let directories;
|
|
103
|
+
try {
|
|
104
|
+
directories = await readdir(agentDir, { withFileTypes: true });
|
|
105
|
+
} catch {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
for (const directory of directories) {
|
|
109
|
+
signal?.throwIfAborted();
|
|
110
|
+
if (!directory.isDirectory() || directory.isSymbolicLink()) continue;
|
|
111
|
+
let entries;
|
|
112
|
+
try {
|
|
113
|
+
entries = await readdir(join(agentDir, directory.name), { withFileTypes: true });
|
|
114
|
+
} catch {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
for (const entry of entries) {
|
|
118
|
+
if (!entry.isFile() || entry.isSymbolicLink() || !USAGE_SIDECAR_NAMES.has(entry.name)) continue;
|
|
119
|
+
try {
|
|
120
|
+
found.add(await realpath(join(agentDir, directory.name, entry.name)));
|
|
121
|
+
} catch {
|
|
122
|
+
// File disappeared between listing and resolution.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return [...found].sort();
|
|
127
|
+
}
|
|
128
|
+
|
|
96
129
|
function emptyCache(): StatsCacheFile {
|
|
97
130
|
return { version: CACHE_VERSION, files: {} };
|
|
98
131
|
}
|
|
@@ -109,7 +142,7 @@ function validUsageRecord(value: unknown): value is UsageRecord {
|
|
|
109
142
|
typeof record.fingerprint === "string" &&
|
|
110
143
|
validNumber(record.timestamp) &&
|
|
111
144
|
typeof record.model === "string" &&
|
|
112
|
-
(record.kind === "assistant" || record.kind === "tool" || record.kind === "summary") &&
|
|
145
|
+
(record.kind === "assistant" || record.kind === "tool" || record.kind === "summary" || record.kind === "sidecar") &&
|
|
113
146
|
Boolean(usage) &&
|
|
114
147
|
validNumber(usage?.input) &&
|
|
115
148
|
validNumber(usage?.output) &&
|
|
@@ -149,7 +182,8 @@ function validCache(value: unknown): value is StatsCacheFile {
|
|
|
149
182
|
validNumber(record.mtimeMs) &&
|
|
150
183
|
validNumber(record.malformedLines) &&
|
|
151
184
|
(record.ignored === undefined || record.ignored === true) &&
|
|
152
|
-
(record.session === undefined || validSessionRecord(record.session))
|
|
185
|
+
(record.session === undefined || validSessionRecord(record.session)) &&
|
|
186
|
+
(record.sidecar === undefined || (Array.isArray(record.sidecar) && record.sidecar.every(validUsageRecord)))
|
|
153
187
|
);
|
|
154
188
|
});
|
|
155
189
|
}
|
|
@@ -207,7 +241,10 @@ export function statsCachePath(agentDir: string): string {
|
|
|
207
241
|
|
|
208
242
|
export async function scanStats(options: ScanOptions): Promise<StatsIndex> {
|
|
209
243
|
const roots = await resolveSessionRoots(options);
|
|
210
|
-
const
|
|
244
|
+
const sessionFiles = await discoverJsonlFiles(roots, options.signal);
|
|
245
|
+
const sidecarFiles = await discoverUsageSidecars(options.agentDir, options.env ?? process.env, options.signal);
|
|
246
|
+
const sidecars = new Set(sidecarFiles);
|
|
247
|
+
const files = [...sessionFiles.filter((file) => !sidecars.has(file)), ...sidecarFiles];
|
|
211
248
|
const cachePath = statsCachePath(options.agentDir);
|
|
212
249
|
await cleanupStaleTemporaryFiles(cachePath);
|
|
213
250
|
const loaded = options.force ? { cache: emptyCache(), valid: false } : await loadCache(cachePath);
|
|
@@ -231,6 +268,16 @@ export async function scanStats(options: ScanOptions): Promise<StatsIndex> {
|
|
|
231
268
|
if (!options.force && sameFile(previous, metadata.size, metadata.mtimeMs)) {
|
|
232
269
|
next.files[file] = previous!;
|
|
233
270
|
diagnostics.reusedFiles++;
|
|
271
|
+
} else if (sidecars.has(file)) {
|
|
272
|
+
const parsed = parseUsageSidecar(file, await readFile(file, "utf8"));
|
|
273
|
+
next.files[file] = {
|
|
274
|
+
size: metadata.size,
|
|
275
|
+
mtimeMs: metadata.mtimeMs,
|
|
276
|
+
sidecar: parsed.records,
|
|
277
|
+
malformedLines: parsed.malformedLines,
|
|
278
|
+
};
|
|
279
|
+
diagnostics.parsedFiles++;
|
|
280
|
+
changed = true;
|
|
234
281
|
} else {
|
|
235
282
|
const parsed = parseSessionText(file, await readFile(file, "utf8"));
|
|
236
283
|
next.files[file] = {
|
|
@@ -260,7 +307,8 @@ export async function scanStats(options: ScanOptions): Promise<StatsIndex> {
|
|
|
260
307
|
});
|
|
261
308
|
}
|
|
262
309
|
const sessions = Object.values(next.files).flatMap((record) => (record.session ? [record.session] : []));
|
|
263
|
-
|
|
310
|
+
const sidecarUsage = Object.values(next.files).flatMap((record) => record.sidecar ?? []);
|
|
311
|
+
return makeIndex(sessions, diagnostics, sidecarUsage);
|
|
264
312
|
}
|
|
265
313
|
|
|
266
314
|
export function isExternalSessionDirectory(activeSessionDir: string, agentDir: string): boolean {
|
package/package.json
CHANGED
package/stats.ts
CHANGED
|
@@ -238,6 +238,49 @@ export function parseSessionText(filePath: string, text: string): ParseResult {
|
|
|
238
238
|
};
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
+
/**
|
|
242
|
+
* Parse a usage sidecar: one content-free JSON record per model call made outside a Pi
|
|
243
|
+
* session transcript, such as pi-auto-permissions guardian reviews.
|
|
244
|
+
*/
|
|
245
|
+
export function parseUsageSidecar(filePath: string, text: string): { records: UsageRecord[]; malformedLines: number } {
|
|
246
|
+
const records: UsageRecord[] = [];
|
|
247
|
+
let malformedLines = 0;
|
|
248
|
+
const lines = text.split("\n");
|
|
249
|
+
for (let index = 0; index < lines.length; index++) {
|
|
250
|
+
const line = lines[index]?.trim();
|
|
251
|
+
if (!line) continue;
|
|
252
|
+
let entry: Record<string, unknown> | undefined;
|
|
253
|
+
try {
|
|
254
|
+
entry = object(JSON.parse(line));
|
|
255
|
+
} catch {
|
|
256
|
+
malformedLines++;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (!entry || entry.v !== 1) {
|
|
260
|
+
malformedLines++;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
const usage = normalizeUsage(entry.usage);
|
|
264
|
+
if (!usage) {
|
|
265
|
+
malformedLines++;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const label = typeof entry.label === "string" && entry.label.length > 0 ? entry.label : undefined;
|
|
269
|
+
const source = typeof entry.source === "string" && entry.source.length > 0 ? entry.source : "sidecar";
|
|
270
|
+
const model = `${modelName(entry.provider, entry.model)} (${label ?? source})`;
|
|
271
|
+
const identity = typeof entry.id === "string" && entry.id.length > 0 ? entry.id : `${resolve(filePath)}:${index}`;
|
|
272
|
+
records.push({
|
|
273
|
+
fingerprint: fingerprint([identity, entry.ts, source, model, usage]),
|
|
274
|
+
timestamp: timestamp(entry.ts, 0),
|
|
275
|
+
model,
|
|
276
|
+
usage,
|
|
277
|
+
kind: "sidecar",
|
|
278
|
+
toolName: source,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
return { records, malformedLines };
|
|
282
|
+
}
|
|
283
|
+
|
|
241
284
|
export function usageFromEntries(
|
|
242
285
|
sessionId: string,
|
|
243
286
|
entries: readonly unknown[],
|
|
@@ -254,7 +297,7 @@ function hasPersistedSubagentChild(record: UsageRecord, sessionPaths: ReadonlySe
|
|
|
254
297
|
return record.toolName === "subagent" && Boolean(record.childSessionFiles?.some((file) => sessionPaths.has(resolve(file))));
|
|
255
298
|
}
|
|
256
299
|
|
|
257
|
-
export function deduplicateUsage(sessions: readonly SessionRecord[]): UsageRecord[] {
|
|
300
|
+
export function deduplicateUsage(sessions: readonly SessionRecord[], sidecar: readonly UsageRecord[] = []): UsageRecord[] {
|
|
258
301
|
const sessionPaths = new Set(sessions.map((session) => resolve(session.path)));
|
|
259
302
|
const seen = new Set<string>();
|
|
260
303
|
const result: UsageRecord[] = [];
|
|
@@ -266,6 +309,11 @@ export function deduplicateUsage(sessions: readonly SessionRecord[]): UsageRecor
|
|
|
266
309
|
result.push(record);
|
|
267
310
|
}
|
|
268
311
|
}
|
|
312
|
+
for (const record of sidecar) {
|
|
313
|
+
if (seen.has(record.fingerprint)) continue;
|
|
314
|
+
seen.add(record.fingerprint);
|
|
315
|
+
result.push(record);
|
|
316
|
+
}
|
|
269
317
|
return result;
|
|
270
318
|
}
|
|
271
319
|
|
|
@@ -400,6 +448,10 @@ export function buildSnapshot(
|
|
|
400
448
|
};
|
|
401
449
|
}
|
|
402
450
|
|
|
403
|
-
export function makeIndex(
|
|
404
|
-
|
|
451
|
+
export function makeIndex(
|
|
452
|
+
sessions: SessionRecord[],
|
|
453
|
+
diagnostics: StatsIndex["diagnostics"],
|
|
454
|
+
sidecar: UsageRecord[] = [],
|
|
455
|
+
): StatsIndex {
|
|
456
|
+
return { sessions, usage: deduplicateUsage(sessions, sidecar), diagnostics };
|
|
405
457
|
}
|
package/types.ts
CHANGED
|
@@ -15,7 +15,7 @@ export interface UsageRecord {
|
|
|
15
15
|
timestamp: number;
|
|
16
16
|
model: string;
|
|
17
17
|
usage: UsageTotals;
|
|
18
|
-
kind: "assistant" | "tool" | "summary";
|
|
18
|
+
kind: "assistant" | "tool" | "summary" | "sidecar";
|
|
19
19
|
toolName?: string;
|
|
20
20
|
childSessionFiles?: string[];
|
|
21
21
|
}
|
|
@@ -35,11 +35,13 @@ export interface CachedFileRecord {
|
|
|
35
35
|
mtimeMs: number;
|
|
36
36
|
ignored?: true;
|
|
37
37
|
session?: SessionRecord;
|
|
38
|
+
/** Usage recorded by extensions whose model calls never reach a session transcript. */
|
|
39
|
+
sidecar?: UsageRecord[];
|
|
38
40
|
malformedLines: number;
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
export interface StatsCacheFile {
|
|
42
|
-
version:
|
|
44
|
+
version: 2;
|
|
43
45
|
files: Record<string, CachedFileRecord>;
|
|
44
46
|
}
|
|
45
47
|
|