@pify/usage 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pifydev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,34 @@
1
+ # @pify/usage
2
+
3
+ Token and cost reporting for [pi](https://github.com/earendil-works/pi) sessions β€” a live footer, a `/usage` dashboard, and an agent-callable status tool. Entirely local: no network calls, no LLM tokens spent asking about tokens.
4
+
5
+ Part of the [Pify suite](https://github.com/pifydev). Install with [`pify install usage`](https://github.com/pifydev/cli) or `pi install npm:@pify/usage`.
6
+
7
+ ## What it does
8
+
9
+ - **Live footer**: `πŸ“Š 12.3k tok Β· $0.45` β€” folded from each message's `usage.cost` that pi already computes; survives `/reload` by replaying the session branch.
10
+ - **`/usage` dashboard**:
11
+
12
+ ```
13
+ Session
14
+ tokens in 120.3k Β· out 8.2k Β· cache 1.1M read / 0 write
15
+ cost $0.45 (23 responses)
16
+ context ~34% of the window
17
+
18
+ History (214 local session files)
19
+ today $1.23 Β· 450.2k tok
20
+ 7 days $8.90 Β· 3.2M tok
21
+ 30 days $21.40 Β· 9.8M tok
22
+ By model (all time)
23
+ anthropic/claude-fable-5 $12.30 Β· 4.1M tok
24
+ openai/gpt-5.5 $9.10 Β· 5.7M tok
25
+ ```
26
+
27
+ - **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.
28
+ - **`usage_status` tool**: the agent can check session + today totals before committing to expensive work (subagent fan-outs, large reads).
29
+
30
+ 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).
31
+
32
+ ## License
33
+
34
+ MIT Β© [Pify maintainers](https://github.com/pifydev)
@@ -0,0 +1,118 @@
1
+ /**
2
+ * @pify/usage β€” token and cost reporting for pi sessions.
3
+ *
4
+ * Live session tracking in the footer (πŸ“Š 12.3k tok Β· $0.45, folded from
5
+ * each message's usage.cost that pi already computes) and a /usage dashboard
6
+ * combining the current session with local history aggregated from pi's
7
+ * session JSONL files β€” zero network calls, zero LLM tokens spent
8
+ * (aporcelli's principle). History counts every usage-bearing entry
9
+ * (assistant turns plus pi 0.81+'s persisted tool-result/compaction usage,
10
+ * tmustier's lesson) with a per-file mtime cache. The usage_status tool
11
+ * lets the agent itself check consumption mid-session.
12
+ *
13
+ * Provider quota APIs (Codex windows, Copilot, OpenRouter…) are deliberately
14
+ * v0.2 β€” @narumitw/pi-usage shows they cost ~18k lines of per-provider
15
+ * contract maintenance.
16
+ */
17
+ import {
18
+ getAgentDir,
19
+ type ExtensionAPI,
20
+ type ExtensionContext,
21
+ } from "@earendil-works/pi-coding-agent";
22
+ import { Type } from "typebox";
23
+ import { join } from "node:path";
24
+
25
+ import { addRecord, aggregate, recordFromEntry, windowTotals } from "../src/aggregate.ts";
26
+ import { footerText, formatCost, formatTokens, historyBlock, sessionBlock } from "../src/format.ts";
27
+ import { scanSessions } from "../src/sessions.ts";
28
+ import { emptyTotals, isRecord, type UsageTotals } from "../src/types.ts";
29
+
30
+ type UiContext = ExtensionContext;
31
+
32
+ export default function usage(pi: ExtensionAPI) {
33
+ let session: UsageTotals = emptyTotals();
34
+ /** input+cacheRead of the most recent assistant message β‰ˆ context size. */
35
+ let lastPromptTokens = 0;
36
+
37
+ function updateFooter(ctx: UiContext): void {
38
+ if (!ctx.hasUI) return;
39
+ ctx.ui.setStatus("usage", footerText(session));
40
+ }
41
+
42
+ function contextPct(ctx: UiContext): number | null {
43
+ const window = (ctx.model as { contextWindow?: number } | null)?.contextWindow;
44
+ if (!window || lastPromptTokens === 0) return null;
45
+ return Math.min(100, (lastPromptTokens / window) * 100);
46
+ }
47
+
48
+ function dashboard(ctx: UiContext): string {
49
+ const history = aggregate(...(() => {
50
+ const scan = scanSessions(join(getAgentDir(), "sessions"));
51
+ return [scan.records, scan.files] as const;
52
+ })());
53
+ return [sessionBlock(session, contextPct(ctx)), historyBlock(history, Date.now())].join("\n\n");
54
+ }
55
+
56
+ // ── Live tracking ────────────────────────────────────────────────────
57
+
58
+ pi.on("message_end", async (event, ctx) => {
59
+ const record = recordFromEntry({ message: (event as { message?: unknown }).message });
60
+ if (!record) return;
61
+ addRecord(session, record);
62
+ const message = (event as { message?: { role?: string; usage?: unknown } }).message;
63
+ if (message?.role === "assistant" && isRecord(message.usage)) {
64
+ const input = message.usage.input;
65
+ const cacheRead = message.usage.cacheRead;
66
+ lastPromptTokens =
67
+ (typeof input === "number" ? input : 0) + (typeof cacheRead === "number" ? cacheRead : 0);
68
+ }
69
+ updateFooter(ctx);
70
+ });
71
+
72
+ pi.on("session_start", async (_event, ctx) => {
73
+ // Rebuild the session totals from the branch so /reload keeps the count.
74
+ session = emptyTotals();
75
+ lastPromptTokens = 0;
76
+ for (const entry of ctx.sessionManager.getBranch()) {
77
+ const record = recordFromEntry(entry);
78
+ if (record) addRecord(session, record);
79
+ }
80
+ updateFooter(ctx);
81
+ });
82
+
83
+ pi.on("session_shutdown", async (_event, ctx) => {
84
+ if (ctx.hasUI) ctx.ui.setStatus("usage", undefined);
85
+ });
86
+
87
+ // ── Command & tool ───────────────────────────────────────────────────
88
+
89
+ pi.registerCommand("usage", {
90
+ description: "Token and cost dashboard: current session + local history",
91
+ handler: async (_args, ctx) => {
92
+ if (ctx.hasUI) ctx.ui.notify(dashboard(ctx), "info");
93
+ },
94
+ });
95
+
96
+ pi.registerTool({
97
+ name: "usage_status",
98
+ label: "Usage status",
99
+ description:
100
+ "Current session token/cost totals plus today's local aggregate. Use when deciding whether " +
101
+ "an expensive approach (large reads, many subagents) is proportionate.",
102
+ parameters: Type.Object({}),
103
+ async execute(_id, _params, _signal, _onUpdate, ctx) {
104
+ const scan = scanSessions(join(getAgentDir(), "sessions"));
105
+ const history = aggregate(scan.records, scan.files);
106
+ const today = windowTotals(history.byDay, 1, Date.now());
107
+ const text = [
108
+ `Session: ${formatTokens(session.totalTokens)} tokens, ${formatCost(session.cost)} across ${session.messages} responses.`,
109
+ `Today (all sessions): ${formatTokens(today.totalTokens)} tokens, ${formatCost(today.cost)}.`,
110
+ ].join("\n");
111
+ void ctx;
112
+ return {
113
+ content: [{ type: "text", text }],
114
+ details: { session, today },
115
+ };
116
+ },
117
+ });
118
+ }
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@pify/usage",
3
+ "version": "0.1.0",
4
+ "description": "Token and cost reporting for pi sessions: live footer, /usage dashboard over local session history, zero network",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "pi",
9
+ "pify",
10
+ "usage",
11
+ "tokens",
12
+ "cost"
13
+ ],
14
+ "homepage": "https://github.com/pifydev/usage#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/pifydev/usage/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/pifydev/usage.git"
21
+ },
22
+ "license": "MIT",
23
+ "author": "Pify maintainers",
24
+ "type": "module",
25
+ "engines": {
26
+ "node": ">=22.19.0"
27
+ },
28
+ "files": [
29
+ "extensions",
30
+ "src",
31
+ "skills",
32
+ "README.md",
33
+ "LICENSE"
34
+ ],
35
+ "pi": {
36
+ "extensions": [
37
+ "./extensions/usage.ts"
38
+ ],
39
+ "skills": [
40
+ "./skills"
41
+ ]
42
+ },
43
+ "scripts": {
44
+ "typecheck": "tsc --noEmit",
45
+ "test": "bun test",
46
+ "prepublishOnly": "npm run typecheck && npm test"
47
+ },
48
+ "peerDependencies": {
49
+ "@earendil-works/pi-coding-agent": "*",
50
+ "typebox": "*"
51
+ },
52
+ "peerDependenciesMeta": {
53
+ "@earendil-works/pi-coding-agent": {
54
+ "optional": true
55
+ },
56
+ "typebox": {
57
+ "optional": true
58
+ }
59
+ },
60
+ "devDependencies": {
61
+ "@earendil-works/pi-coding-agent": "^0.84.4",
62
+ "@types/node": "^22.10.2",
63
+ "typebox": "^1.1.38",
64
+ "typescript": "^5.7.2"
65
+ },
66
+ "publishConfig": {
67
+ "access": "public"
68
+ }
69
+ }
@@ -0,0 +1,25 @@
1
+ ---
2
+ name: usage
3
+ description: Use when cost or token consumption matters to a decision - before expensive operations (large reads, many subagents, long loops) or when the user asks about spend - explains usage_status and /usage
4
+ ---
5
+
6
+ # Usage awareness
7
+
8
+ This project has the `@pify/usage` extension installed: a live footer
9
+ (tokens + cost for this session) and local history aggregation.
10
+
11
+ ## When to check (usage_status)
12
+
13
+ - Before an expensive plan: fanning out subagents, reading many large
14
+ files, or long automatic loops β€” confirm the spend is proportionate.
15
+ - When the user asks "how much has this cost?" β€” answer from usage_status,
16
+ never estimate from memory.
17
+
18
+ ## Notes
19
+
20
+ - All numbers are computed locally from pi's session files and the current
21
+ session's message usage β€” no network, no tokens spent asking.
22
+ - `/usage` (user command) shows the full dashboard: session breakdown,
23
+ today/7d/30d history, and per-model totals.
24
+ - Costs come from pi's own per-message usage.cost; if a model has no price
25
+ configured, its cost reads as $0 while tokens still count.
@@ -0,0 +1,112 @@
1
+ import {
2
+ emptyTotals,
3
+ finite,
4
+ isRecord,
5
+ type HistoryAggregate,
6
+ type UsageRecord,
7
+ type UsageTotals,
8
+ } from "./types.ts";
9
+
10
+ /** Fold one record into a totals accumulator (mutates and returns it). */
11
+ export function addRecord(totals: UsageTotals, record: UsageRecord): UsageTotals {
12
+ totals.input += record.input;
13
+ totals.output += record.output;
14
+ totals.cacheRead += record.cacheRead;
15
+ totals.cacheWrite += record.cacheWrite;
16
+ totals.totalTokens += record.totalTokens;
17
+ totals.cost += record.cost;
18
+ totals.messages += 1;
19
+ return totals;
20
+ }
21
+
22
+ function pad2(n: number): string {
23
+ return String(n).padStart(2, "0");
24
+ }
25
+
26
+ /** Local calendar day key (matches the suite's local-day discipline). */
27
+ export function dayKey(timestamp: number): string {
28
+ const d = new Date(timestamp);
29
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
30
+ }
31
+
32
+ export function aggregate(records: UsageRecord[], files: number): HistoryAggregate {
33
+ const byDay = new Map<string, UsageTotals>();
34
+ const byModel = new Map<string, UsageTotals>();
35
+ const total = emptyTotals();
36
+
37
+ for (const record of records) {
38
+ addRecord(total, record);
39
+ const day = dayKey(record.timestamp);
40
+ addRecord(byDay.get(day) ?? byDay.set(day, emptyTotals()).get(day)!, record);
41
+ const model = `${record.provider}/${record.model}`;
42
+ addRecord(byModel.get(model) ?? byModel.set(model, emptyTotals()).get(model)!, record);
43
+ }
44
+
45
+ return { byDay, byModel, total, files };
46
+ }
47
+
48
+ /** Sum totals for days within the trailing window (inclusive of today). */
49
+ export function windowTotals(byDay: Map<string, UsageTotals>, days: number, now: number): UsageTotals {
50
+ const totals = emptyTotals();
51
+ const cutoff = new Set<string>();
52
+ for (let i = 0; i < days; i++) {
53
+ cutoff.add(dayKey(now - i * 86_400_000));
54
+ }
55
+ for (const [day, t] of byDay) {
56
+ if (!cutoff.has(day)) continue;
57
+ totals.input += t.input;
58
+ totals.output += t.output;
59
+ totals.cacheRead += t.cacheRead;
60
+ totals.cacheWrite += t.cacheWrite;
61
+ totals.totalTokens += t.totalTokens;
62
+ totals.cost += t.cost;
63
+ totals.messages += t.messages;
64
+ }
65
+ return totals;
66
+ }
67
+
68
+ /**
69
+ * Extract a usage record from one parsed session-JSONL entry. Counts any
70
+ * entry whose message carries a usage block (assistant turns dominate; pi
71
+ * 0.81+ also persists tool-result/compaction usage the same way). Returns
72
+ * null for entries without usage. Negative/NaN fields clamp to 0.
73
+ */
74
+ export function recordFromEntry(entry: unknown): UsageRecord | null {
75
+ if (!isRecord(entry)) return null;
76
+ const message = entry.message;
77
+ if (!isRecord(message) || !isRecord(message.usage)) return null;
78
+ const usage = message.usage;
79
+ const total = finite(usage.totalTokens);
80
+ const cost = isRecord(usage.cost) ? finite(usage.cost.total) : 0;
81
+ if (total === 0 && cost === 0) return null;
82
+
83
+ const ts =
84
+ typeof entry.timestamp === "string"
85
+ ? Date.parse(entry.timestamp)
86
+ : typeof message.timestamp === "number"
87
+ ? message.timestamp
88
+ : Number.NaN;
89
+
90
+ return {
91
+ timestamp: Number.isFinite(ts) ? ts : 0,
92
+ model: typeof message.model === "string" ? message.model : "unknown",
93
+ provider: typeof message.provider === "string" ? message.provider : "unknown",
94
+ input: finite(usage.input),
95
+ output: finite(usage.output),
96
+ cacheRead: finite(usage.cacheRead),
97
+ cacheWrite: finite(usage.cacheWrite),
98
+ totalTokens: total,
99
+ cost,
100
+ };
101
+ }
102
+
103
+ /** Parse one JSONL line into a usage record, or null. */
104
+ export function recordFromLine(line: string): UsageRecord | null {
105
+ const trimmed = line.trim();
106
+ if (!trimmed) return null;
107
+ try {
108
+ return recordFromEntry(JSON.parse(trimmed));
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
package/src/format.ts ADDED
@@ -0,0 +1,58 @@
1
+ import type { HistoryAggregate, UsageTotals } from "./types.ts";
2
+ import { windowTotals } from "./aggregate.ts";
3
+
4
+ export function formatTokens(n: number): string {
5
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
6
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
7
+ return String(Math.round(n));
8
+ }
9
+
10
+ export function formatCost(n: number): string {
11
+ if (n === 0) return "$0";
12
+ if (n < 0.01) return `<$0.01`;
13
+ return `$${n.toFixed(2)}`;
14
+ }
15
+
16
+ /** Footer text: short, live. Null clears the indicator. */
17
+ export function footerText(session: UsageTotals): string | undefined {
18
+ if (session.messages === 0) return undefined;
19
+ return `πŸ“Š ${formatTokens(session.totalTokens)} tok Β· ${formatCost(session.cost)}`;
20
+ }
21
+
22
+ export function sessionBlock(session: UsageTotals, contextPct: number | null): string {
23
+ const lines = [
24
+ "Session",
25
+ ` tokens in ${formatTokens(session.input)} Β· out ${formatTokens(session.output)} Β· cache ${formatTokens(session.cacheRead)} read / ${formatTokens(session.cacheWrite)} write`,
26
+ ` cost ${formatCost(session.cost)} (${session.messages} responses)`,
27
+ ];
28
+ if (contextPct !== null) {
29
+ lines.push(` context ~${Math.round(contextPct)}% of the window`);
30
+ }
31
+ return lines.join("\n");
32
+ }
33
+
34
+ export function historyBlock(history: HistoryAggregate, now: number): string {
35
+ const today = windowTotals(history.byDay, 1, now);
36
+ const week = windowTotals(history.byDay, 7, now);
37
+ const month = windowTotals(history.byDay, 30, now);
38
+
39
+ const lines = [
40
+ `History (${history.files} local session files)`,
41
+ ` today ${formatCost(today.cost)} Β· ${formatTokens(today.totalTokens)} tok`,
42
+ ` 7 days ${formatCost(week.cost)} Β· ${formatTokens(week.totalTokens)} tok`,
43
+ ` 30 days ${formatCost(month.cost)} Β· ${formatTokens(month.totalTokens)} tok`,
44
+ ];
45
+
46
+ const models = [...history.byModel.entries()]
47
+ .sort((a, b) => b[1].cost - a[1].cost)
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
+ }
57
+ return lines.join("\n");
58
+ }
@@ -0,0 +1,70 @@
1
+ import { readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { recordFromLine } from "./aggregate.ts";
4
+ import type { UsageRecord } from "./types.ts";
5
+
6
+ /**
7
+ * Scan pi's session store for usage records. Zero network, zero LLM tokens
8
+ * (aporcelli's principle): everything is computed from local JSONL files.
9
+ * Per-file mtime+size cache keeps repeat scans cheap.
10
+ */
11
+
12
+ const MAX_FILE_BYTES = 64 * 1024 * 1024;
13
+
14
+ const cache = new Map<string, { mtimeMs: number; size: number; records: UsageRecord[] }>();
15
+
16
+ export function clearScanCache(): void {
17
+ cache.clear();
18
+ }
19
+
20
+ function listJsonlFiles(dir: string, depth: number): string[] {
21
+ if (depth > 4) return [];
22
+ let names: string[];
23
+ try {
24
+ names = readdirSync(dir);
25
+ } catch {
26
+ return [];
27
+ }
28
+ const files: string[] = [];
29
+ for (const name of names) {
30
+ const full = join(dir, name);
31
+ try {
32
+ const stat = statSync(full);
33
+ if (stat.isDirectory()) files.push(...listJsonlFiles(full, depth + 1));
34
+ else if (name.endsWith(".jsonl") && stat.size <= MAX_FILE_BYTES) files.push(full);
35
+ } catch {
36
+ // race/permission β€” skip
37
+ }
38
+ }
39
+ return files;
40
+ }
41
+
42
+ export interface ScanResult {
43
+ records: UsageRecord[];
44
+ files: number;
45
+ }
46
+
47
+ export function scanSessions(sessionsDir: string): ScanResult {
48
+ const files = listJsonlFiles(sessionsDir, 0);
49
+ const records: UsageRecord[] = [];
50
+ for (const file of files) {
51
+ try {
52
+ const stat = statSync(file);
53
+ const cached = cache.get(file);
54
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
55
+ records.push(...cached.records);
56
+ continue;
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);
62
+ }
63
+ cache.set(file, { mtimeMs: stat.mtimeMs, size: stat.size, records: fileRecords });
64
+ records.push(...fileRecords);
65
+ } catch {
66
+ // unreadable β€” skip
67
+ }
68
+ }
69
+ return { records, files: files.length };
70
+ }
package/src/types.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Local structural types for @pify/usage.
3
+ * No imports from pi packages: src/ typechecks and runs standalone.
4
+ */
5
+
6
+ export interface UsageTotals {
7
+ input: number;
8
+ output: number;
9
+ cacheRead: number;
10
+ cacheWrite: number;
11
+ totalTokens: number;
12
+ cost: number;
13
+ messages: number;
14
+ }
15
+
16
+ export function emptyTotals(): UsageTotals {
17
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: 0, messages: 0 };
18
+ }
19
+
20
+ /** One usage-bearing record extracted from a session file or live event. */
21
+ export interface UsageRecord {
22
+ /** Epoch ms. */
23
+ timestamp: number;
24
+ model: string;
25
+ provider: string;
26
+ input: number;
27
+ output: number;
28
+ cacheRead: number;
29
+ cacheWrite: number;
30
+ totalTokens: number;
31
+ cost: number;
32
+ }
33
+
34
+ export interface HistoryAggregate {
35
+ byDay: Map<string, UsageTotals>;
36
+ byModel: Map<string, UsageTotals>;
37
+ total: UsageTotals;
38
+ files: number;
39
+ }
40
+
41
+ export function isRecord(value: unknown): value is Record<string, unknown> {
42
+ return typeof value === "object" && value !== null && !Array.isArray(value);
43
+ }
44
+
45
+ export function finite(value: unknown): number {
46
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
47
+ }