@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 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 = 1 as const;
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 files = await discoverJsonlFiles(roots, options.signal);
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
- return makeIndex(sessions, diagnostics);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-stats",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A compact /stats dashboard for all-time Pi token usage, including persisted subagents.",
5
5
  "type": "module",
6
6
  "keywords": [
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(sessions: SessionRecord[], diagnostics: StatsIndex["diagnostics"]): StatsIndex {
404
- return { sessions, usage: deduplicateUsage(sessions), diagnostics };
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: 1;
44
+ version: 2;
43
45
  files: Record<string, CachedFileRecord>;
44
46
  }
45
47