@pify/usage 0.11.0 → 0.12.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
@@ -44,6 +44,12 @@ By project (all time)
44
44
  D--project-shop-api $6.60 · 3.6M tok
45
45
  ```
46
46
 
47
+ `/usage export` writes the history out — `by-model.csv`, `by-day.csv`, `by-project.csv` and `totals.json`, full precision — into a timestamped folder under the OS temp directory (or `PIFY_USAGE_EXPORT_DIR`), never into the repository or your home, and prints the path.
48
+
49
+ The per-file scan cache is also kept on disk (`usage-scan-cache.json` next to pi's other state, keyed by size and mtime, versioned), so a fresh `pi` no longer pays the whole cold scan on its first `/usage`: it is read once before the first scan and rewritten after each completed one; a corrupt or older file is ignored and rebuilt, and only files the latest scan saw are kept.
50
+
51
+ Once a session has shown a healthy prompt-cache hit rate on a large prompt, five large prompts in a row at ~0% hit raise one warning naming the likely causes — a model or thinking-level switch, a provider dropping its cache, something rewriting the start of the context each turn — because from then on every turn re-bills the whole prefix and the footer alone only shows the spend climbing.
52
+
47
53
  History counts every usage-bearing entry in pi's session JSONL — assistant turns plus the tool-result usage pi persists, and the summariser's own call, which pi records on the compaction and branch-summary entries themselves rather than on a message. Those summary calls are among the most expensive in a session, so counting them is what makes a session's total match what you were billed rather than what the visible messages add up to; they have no model of their own, so they group under a `compaction` / `branch_summary` label in the by-model view. The live footer and session totals fold the same summariser usage in as it happens (on compaction and tree navigation), so the number does not jump only after a reload. pi 0.87's cache warmer spends real money outside any message too, as standalone `usage` entries; those are counted under their own model on reload and in history (pi raises no extension event for them, so the live footer picks them up at the next session start). Negative and NaN fields clamp to zero, days are your **local** calendar days, and a per-file mtime cache keeps repeat scans instant.
48
54
 
49
55
  Per-project totals come for free: pi stores sessions one directory per project, so the dashboard can show where the money actually went.
@@ -32,9 +32,13 @@ import { addRecord, aggregate, recordFromEntry, windowTotals } from "../src/aggr
32
32
  import { buildBreakdown, contextTokensFromUsage, formatBreakdown, resolveUsedTokens } from "../src/context.ts";
33
33
  import { contextGauge, footerText, formatCost, formatTokens, historyBlock, sessionBlock } from "../src/format.ts";
34
34
  import { childSpendTotal, onChildSpend, resetChildSpend } from "../src/child-cost.ts";
35
+ import { buildByDayCsv, buildByModelCsv, buildByProjectCsv, buildTotalsJson } from "../src/export.ts";
36
+ import { emptyCacheHealth, observeCacheHealth } from "../src/cache-health.ts";
37
+ import { mkdirSync, writeFileSync } from "node:fs";
38
+ import { tmpdir } from "node:os";
35
39
  import { QUOTA_PROVIDERS, fetchQuota, quotaReport, type QuotaResult } from "../src/quota.ts";
36
40
  import { redact } from "../src/redact.ts";
37
- import { scanSessions } from "../src/sessions.ts";
41
+ import { scanSessions, persistScanCache, restoreScanCache } from "../src/sessions.ts";
38
42
  import { parseRateLimit, formatRateLimit, type RateLimitSnapshot, footerQuotaSegment } from "../src/ratelimit.ts";
39
43
  import { emptyTotals, isRecord, type UsageTotals } from "../src/types.ts";
40
44
 
@@ -112,9 +116,25 @@ export default function usage(pi: ExtensionAPI) {
112
116
  return { pct: Math.min(100, (used / window) * 100), window };
113
117
  }
114
118
 
119
+ /** The on-disk twin of the scan cache: read once before the first scan, written after each completed one. */
120
+ const scanCacheFile = () => join(getAgentDir(), "usage-scan-cache.json");
121
+ let scanCacheRestored = false;
122
+ function scanHistory() {
123
+ if (!scanCacheRestored) {
124
+ scanCacheRestored = true;
125
+ restoreScanCache(scanCacheFile());
126
+ }
127
+ const scan = scanSessions(join(getAgentDir(), "sessions"));
128
+ persistScanCache(scanCacheFile());
129
+ return scan;
130
+ }
131
+
132
+ /** Prompt-cache health for this session: warns once when a warm cache goes cold. */
133
+ let cacheHealth = emptyCacheHealth();
134
+
115
135
  function dashboard(ctx: UiContext): string {
116
136
  const history = aggregate(...(() => {
117
- const scan = scanSessions(join(getAgentDir(), "sessions"));
137
+ const scan = scanHistory();
118
138
  return [scan.records, scan.files] as const;
119
139
  })());
120
140
  return [sessionBlock(session, contextInfo(ctx).pct, childSpendTotal()), historyBlock(history, Date.now())].join("\n\n");
@@ -128,6 +148,8 @@ export default function usage(pi: ExtensionAPI) {
128
148
  addRecord(session, record);
129
149
  const message = (event as { message?: { role?: string; usage?: unknown } }).message;
130
150
  if (message?.role === "assistant" && isRecord(message.usage)) {
151
+ const warning = observeCacheHealth(cacheHealth, message.usage as { input?: number; cacheRead?: number; cacheWrite?: number });
152
+ if (warning && ctx.hasUI) ctx.ui.notify(warning, "warning");
131
153
  // Use pi's own formula so the gauge cannot drift below pi's reading —
132
154
  // input+cacheRead alone dropped cacheWrite and output, a real slice of
133
155
  // every Anthropic prompt-cached turn.
@@ -143,6 +165,7 @@ export default function usage(pi: ExtensionAPI) {
143
165
  // Child spend is not in the branch, so a /reload must keep the tally; a
144
166
  // new, resumed or forked session starts its own.
145
167
  if ((event as { reason?: string }).reason !== "reload") resetChildSpend();
168
+ cacheHealth = emptyCacheHealth();
146
169
  footerCtx = ctx;
147
170
  stopChildSpend?.();
148
171
  stopChildSpend = onChildSpend(() => {
@@ -297,9 +320,31 @@ export default function usage(pi: ExtensionAPI) {
297
320
  }
298
321
 
299
322
  pi.registerCommand("usage", {
300
- description: "Token and cost dashboard: /usage [context | quota]",
323
+ description: "Token and cost dashboard: /usage [context | quota | export]",
301
324
  handler: async (args, ctx) => {
302
325
  if (!ctx.hasUI) return;
326
+ if ((args ?? "").trim().toLowerCase() === "export") {
327
+ // Hand the history off: CSV per model/day/project and a totals JSON,
328
+ // in the OS temp dir by default (never the repo or home), or
329
+ // PIFY_USAGE_EXPORT_DIR. Full precision; the reader rounds.
330
+ const scan = scanHistory();
331
+ const history = aggregate(scan.records, scan.files);
332
+ const now = Date.now();
333
+ const stamp = new Date(now).toISOString().replace(/[:.]/g, "-");
334
+ const dir = join(process.env.PIFY_USAGE_EXPORT_DIR?.trim() || join(tmpdir(), "pify-usage"), stamp);
335
+ try {
336
+ mkdirSync(dir, { recursive: true });
337
+ writeFileSync(join(dir, "by-model.csv"), buildByModelCsv(history.byModel));
338
+ writeFileSync(join(dir, "by-day.csv"), buildByDayCsv(history.byDay));
339
+ writeFileSync(join(dir, "by-project.csv"), buildByProjectCsv(history.byProject));
340
+ writeFileSync(join(dir, "totals.json"), buildTotalsJson(history.total, history.files, now));
341
+ } catch (err) {
342
+ ctx.ui.notify(`Export failed: ${err instanceof Error ? err.message : String(err)}`, "error");
343
+ return;
344
+ }
345
+ ctx.ui.notify(`✓ Exported by-model.csv, by-day.csv, by-project.csv and totals.json to ${dir}`, "info");
346
+ return;
347
+ }
303
348
  if ((args ?? "").trim().toLowerCase() === "context") {
304
349
  // Documented since the breakdown landed, but never wired — the handler
305
350
  // only knew "quota", so `/usage context` quietly showed the dashboard.
@@ -361,7 +406,7 @@ export default function usage(pi: ExtensionAPI) {
361
406
  "is proportionate, or whether to /compact first.",
362
407
  parameters: Type.Object({}),
363
408
  async execute(_id, _params, _signal, _onUpdate, ctx) {
364
- const scan = scanSessions(join(getAgentDir(), "sessions"));
409
+ const scan = scanHistory();
365
410
  const history = aggregate(scan.records, scan.files);
366
411
  const today = windowTotals(history.byDay, 1, Date.now());
367
412
  const lines = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/usage",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Token and cost reporting for pi sessions: live footer, local-history dashboard with per-project costs, opt-in provider quota",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Noticing when the prompt cache stops working.
3
+ *
4
+ * A session that enjoyed a warm prefix cache can lose it mid-way — a model
5
+ * switch, a provider dropping its cached KV, a context edit that rewrote
6
+ * the prefix — and from then on every turn re-bills the whole prompt cold.
7
+ * The footer shows the spend climbing; nothing names the cause. This is a
8
+ * small state machine fed with each assistant message's usage: once the
9
+ * session has shown a healthy hit rate on a large prompt, a run of large
10
+ * prompts at ~0% hit trips one warning, and only one. Pure; the extension
11
+ * feeds it and resets it at session start. (billion-context's cache-warn,
12
+ * reduced to the in-process case — a restart is a new session here.)
13
+ */
14
+
15
+ export const MIN_PROMPT_TOKENS = 8_000;
16
+ export const HEALTHY_HIT = 0.5;
17
+ export const COLLAPSED_HIT = 0.05;
18
+ export const LOW_RUN = 5;
19
+
20
+ export interface CacheHealthState {
21
+ sawHealthy: boolean;
22
+ lowRun: number;
23
+ warned: boolean;
24
+ lastHealthyHit: number;
25
+ }
26
+
27
+ export function emptyCacheHealth(): CacheHealthState {
28
+ return { sawHealthy: false, lowRun: 0, warned: false, lastHealthyHit: 0 };
29
+ }
30
+
31
+ export interface PromptUsageLike {
32
+ input?: number;
33
+ cacheRead?: number;
34
+ cacheWrite?: number;
35
+ }
36
+
37
+ /** The share of the prompt that came from the cache; null when the prompt is too small to judge. */
38
+ export function cacheHit(usage: PromptUsageLike): number | null {
39
+ const input = Math.max(0, usage.input ?? 0);
40
+ const read = Math.max(0, usage.cacheRead ?? 0);
41
+ const write = Math.max(0, usage.cacheWrite ?? 0);
42
+ const prompt = input + read + write;
43
+ if (prompt < MIN_PROMPT_TOKENS) return null;
44
+ return read / prompt;
45
+ }
46
+
47
+ /** Feed one assistant message; returns the warning text exactly once, when the collapse is confirmed. */
48
+ export function observeCacheHealth(state: CacheHealthState, usage: PromptUsageLike): string | null {
49
+ const hit = cacheHit(usage);
50
+ if (hit === null) return null;
51
+ if (hit >= HEALTHY_HIT) {
52
+ state.sawHealthy = true;
53
+ state.lastHealthyHit = hit;
54
+ state.lowRun = 0;
55
+ return null;
56
+ }
57
+ if (!state.sawHealthy || hit > COLLAPSED_HIT) {
58
+ state.lowRun = 0;
59
+ return null;
60
+ }
61
+ state.lowRun++;
62
+ if (state.lowRun < LOW_RUN || state.warned) return null;
63
+ state.warned = true;
64
+ return (
65
+ `Prompt cache collapsed: ${LOW_RUN} large prompts in a row at ~${Math.round(hit * 100)}% cache hit after a healthy ` +
66
+ `${Math.round(state.lastHealthyHit * 100)}%. Every turn is now re-billing the whole prefix. Likely causes: a model ` +
67
+ `or thinking-level switch, a provider dropping its cache, or something rewriting the start of the context each turn.`
68
+ );
69
+ }
package/src/export.ts ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Handing the history off: CSV for a spreadsheet, JSON for a script.
3
+ * Pure builders over the aggregate the dashboard already computes; the
4
+ * extension owns the disk write. Full precision, no rounding — rounding is
5
+ * the reader's call.
6
+ */
7
+ import type { UsageTotals } from "./types.ts";
8
+
9
+ function csvCell(value: string | number): string {
10
+ const text = String(value);
11
+ return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
12
+ }
13
+
14
+ function totalsRow(key: string, t: UsageTotals): string {
15
+ return [key, t.messages, t.input, t.output, t.cacheRead, t.cacheWrite, t.totalTokens, t.cost].map(csvCell).join(",");
16
+ }
17
+
18
+ const HEADER = "input,output,cacheRead,cacheWrite,totalTokens,cost";
19
+
20
+ /** One row per model, costliest first. */
21
+ export function buildByModelCsv(byModel: ReadonlyMap<string, UsageTotals>): string {
22
+ const rows = [...byModel.entries()].sort((a, b) => b[1].cost - a[1].cost).map(([model, t]) => totalsRow(model, t));
23
+ return [`model,messages,${HEADER}`, ...rows].join("\n") + "\n";
24
+ }
25
+
26
+ /** One row per local calendar day, oldest first. */
27
+ export function buildByDayCsv(byDay: ReadonlyMap<string, UsageTotals>): string {
28
+ const rows = [...byDay.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([day, t]) => totalsRow(day, t));
29
+ return [`day,messages,${HEADER}`, ...rows].join("\n") + "\n";
30
+ }
31
+
32
+ /** One row per project, costliest first. */
33
+ export function buildByProjectCsv(byProject: ReadonlyMap<string, UsageTotals>): string {
34
+ const rows = [...byProject.entries()].sort((a, b) => b[1].cost - a[1].cost).map(([p, t]) => totalsRow(p || "(no project)", t));
35
+ return [`project,messages,${HEADER}`, ...rows].join("\n") + "\n";
36
+ }
37
+
38
+ export function buildTotalsJson(total: UsageTotals, files: number, generatedAt: number): string {
39
+ return `${JSON.stringify({ generatedAt: new Date(generatedAt).toISOString(), files, total }, null, 2)}\n`;
40
+ }
package/src/sessions.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { readFileSync, readdirSync, statSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
3
  import { recordFromLine } from "./aggregate.ts";
4
4
  import type { UsageRecord } from "./types.ts";
5
5
 
@@ -12,9 +12,66 @@ import type { UsageRecord } from "./types.ts";
12
12
  const MAX_FILE_BYTES = 64 * 1024 * 1024;
13
13
 
14
14
  const cache = new Map<string, { mtimeMs: number; size: number; records: UsageRecord[] }>();
15
+ /** Files the latest scan saw; only these are persisted, so a moved store does not grow the cache forever. */
16
+ let lastSeen = new Set<string>();
15
17
 
16
18
  export function clearScanCache(): void {
17
19
  cache.clear();
20
+ lastSeen = new Set();
21
+ }
22
+
23
+ /** Bump when the cached record shape changes; an older file is ignored and rebuilt. */
24
+ export const SCAN_CACHE_VERSION = 1;
25
+ /** Entries kept on disk; beyond it the oldest-modified files are dropped. */
26
+ export const SCAN_CACHE_MAX_ENTRIES = 5000;
27
+
28
+ /**
29
+ * The in-process cache only ever helped within one pi process, and the
30
+ * dashboard is opened about once per session — so every fresh pi paid the
31
+ * whole cold scan (seconds, for a large store) on the first /usage. The
32
+ * same Map, written once after a completed scan and read back before the
33
+ * first, makes a warm open cheap. Keyed by size+mtime exactly like the
34
+ * in-memory check, so a stale entry is simply re-read; corrupt, missing or
35
+ * older-version files start empty. Last writer wins — no lock: the cost of
36
+ * a lost write is one cold scan. (pi-usage-extension's cache, simplified.)
37
+ */
38
+ export function restoreScanCache(file: string): number {
39
+ let raw: unknown;
40
+ try {
41
+ raw = JSON.parse(readFileSync(file, "utf8"));
42
+ } catch {
43
+ return 0;
44
+ }
45
+ if (!raw || typeof raw !== "object" || (raw as { version?: unknown }).version !== SCAN_CACHE_VERSION) return 0;
46
+ const entries = (raw as { entries?: unknown }).entries;
47
+ if (!entries || typeof entries !== "object") return 0;
48
+ let loaded = 0;
49
+ for (const [path, value] of Object.entries(entries as Record<string, unknown>)) {
50
+ const v = value as { mtimeMs?: unknown; size?: unknown; records?: unknown };
51
+ if (typeof v?.mtimeMs !== "number" || typeof v.size !== "number" || !Array.isArray(v.records)) continue;
52
+ cache.set(path, { mtimeMs: v.mtimeMs, size: v.size, records: v.records as UsageRecord[] });
53
+ loaded++;
54
+ }
55
+ return loaded;
56
+ }
57
+
58
+ export function persistScanCache(file: string): number {
59
+ const kept = [...cache.entries()]
60
+ .filter(([path]) => lastSeen.has(path))
61
+ .sort((a, b) => b[1].mtimeMs - a[1].mtimeMs)
62
+ .slice(0, SCAN_CACHE_MAX_ENTRIES);
63
+ const entries: Record<string, { mtimeMs: number; size: number; records: UsageRecord[] }> = {};
64
+ for (const [path, value] of kept) entries[path] = value;
65
+ try {
66
+ mkdirSync(dirname(file), { recursive: true });
67
+ const tmp = `${file}.${process.pid}.tmp`;
68
+ writeFileSync(tmp, JSON.stringify({ version: SCAN_CACHE_VERSION, entries }));
69
+ renameSync(tmp, file);
70
+ } catch {
71
+ // An unwritable cache costs the next process one cold scan, nothing else.
72
+ return 0;
73
+ }
74
+ return kept.length;
18
75
  }
19
76
 
20
77
  function listJsonlFiles(dir: string, depth: number): string[] {
@@ -87,9 +144,11 @@ function groupByProject(sessionsDir: string): Array<{ project: string; files: st
87
144
  export function scanSessions(sessionsDir: string): ScanResult {
88
145
  const records: UsageRecord[] = [];
89
146
  let fileCount = 0;
147
+ const seen = new Set<string>();
90
148
  for (const { project, files } of groupByProject(sessionsDir)) {
91
149
  fileCount += files.length;
92
150
  for (const file of files) {
151
+ seen.add(file);
93
152
  try {
94
153
  const stat = statSync(file);
95
154
  const cached = cache.get(file);
@@ -109,5 +168,6 @@ export function scanSessions(sessionsDir: string): ScanResult {
109
168
  }
110
169
  }
111
170
  }
171
+ lastSeen = seen;
112
172
  return { records, files: fileCount };
113
173
  }