@pify/usage 0.11.0 → 0.12.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/README.md +6 -0
- package/extensions/usage.ts +53 -4
- package/package.json +1 -1
- package/src/cache-health.ts +69 -0
- package/src/export.ts +40 -0
- package/src/sessions.ts +62 -2
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, readable by you only, 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.
|
package/extensions/usage.ts
CHANGED
|
@@ -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 =
|
|
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,35 @@ 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
|
+
// Spend and project names are the user's business: the folder and
|
|
336
|
+
// files are readable by the user only, and `wx` never follows a link
|
|
337
|
+
// someone else placed at the path (pi 1.0.3's own output-file rule).
|
|
338
|
+
const fileOptions = { mode: 0o600, flag: "wx" } as const;
|
|
339
|
+
try {
|
|
340
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
341
|
+
writeFileSync(join(dir, "by-model.csv"), buildByModelCsv(history.byModel), fileOptions);
|
|
342
|
+
writeFileSync(join(dir, "by-day.csv"), buildByDayCsv(history.byDay), fileOptions);
|
|
343
|
+
writeFileSync(join(dir, "by-project.csv"), buildByProjectCsv(history.byProject), fileOptions);
|
|
344
|
+
writeFileSync(join(dir, "totals.json"), buildTotalsJson(history.total, history.files, now), fileOptions);
|
|
345
|
+
} catch (err) {
|
|
346
|
+
ctx.ui.notify(`Export failed: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
ctx.ui.notify(`✓ Exported by-model.csv, by-day.csv, by-project.csv and totals.json to ${dir}`, "info");
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
303
352
|
if ((args ?? "").trim().toLowerCase() === "context") {
|
|
304
353
|
// Documented since the breakdown landed, but never wired — the handler
|
|
305
354
|
// only knew "quota", so `/usage context` quietly showed the dashboard.
|
|
@@ -361,7 +410,7 @@ export default function usage(pi: ExtensionAPI) {
|
|
|
361
410
|
"is proportionate, or whether to /compact first.",
|
|
362
411
|
parameters: Type.Object({}),
|
|
363
412
|
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
364
|
-
const scan =
|
|
413
|
+
const scan = scanHistory();
|
|
365
414
|
const history = aggregate(scan.records, scan.files);
|
|
366
415
|
const today = windowTotals(history.byDay, 1, Date.now());
|
|
367
416
|
const lines = [
|
package/package.json
CHANGED
|
@@ -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 }), { mode: 0o600 });
|
|
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
|
}
|