@ohgodtamit/pi-usage 0.1.0-alpha.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/src/cache.ts ADDED
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Persistent, incremental scan cache.
3
+ *
4
+ * Re-reading and JSON-parsing every session file on each `/usage` open is the
5
+ * dominant cost (hundreds of files, hundreds of MB). Session files are almost
6
+ * all immutable once written, so we cache the *attributed turns* per session
7
+ * keyed by the file's mtime + size. A later scan only re-parses files whose
8
+ * mtime/size changed; everything else is reused from this cache.
9
+ *
10
+ * The cache is keyed by a `pricesKey` fingerprint too, because manual prices
11
+ * are baked into each turn's cost at parse time — if prices change, the whole
12
+ * cache is rebuilt.
13
+ */
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { dirname, join } from "node:path";
16
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
17
+ import type { ModelPrice } from "./config.ts";
18
+ import type { ChildSessionSummary, SoftRecord, TurnEntry } from "./aggregate.ts";
19
+
20
+ /** Bump when the cached entry shape changes, to invalidate stale caches. */
21
+ const CACHE_VERSION = 6;
22
+
23
+ export interface CachedSession {
24
+ mtimeMs: number;
25
+ size: number;
26
+ entries: TurnEntry[];
27
+ child?: ChildSessionSummary;
28
+ /** `subagents:record` metadata entries found in this transcript. */
29
+ records?: SoftRecord[];
30
+ }
31
+
32
+ export interface ScanCache {
33
+ version: number;
34
+ /** Fingerprint of the price table the cached costs were computed with. */
35
+ pricesKey: string;
36
+ /** Fingerprint of excluded project prefixes used during attribution. */
37
+ excludesKey: string;
38
+ /** Per-session-file cached attribution, keyed by absolute file path. */
39
+ sessions: Record<string, CachedSession>;
40
+ }
41
+
42
+ /** Stable fingerprint of the price table (key order-independent). */
43
+ export function excludesFingerprint(excludes: string[] | undefined): string {
44
+ return (excludes ?? [])
45
+ .map((value) => value.replace(/\\/g, "/").toLowerCase())
46
+ .sort()
47
+ .join("|");
48
+ }
49
+
50
+ export function pricesFingerprint(prices: Record<string, ModelPrice> | undefined): string {
51
+ if (!prices) return "";
52
+ const keys = Object.keys(prices).sort();
53
+ const parts = keys.map((k) => {
54
+ const p = prices[k];
55
+ return `${k}:${p.input ?? 0},${p.output ?? 0},${p.cacheRead ?? 0},${p.cacheWrite ?? 0}`;
56
+ });
57
+ return parts.join("|");
58
+ }
59
+
60
+ function cachePath(): string {
61
+ return join(getAgentDir(), "usage-cache.json");
62
+ }
63
+
64
+ /** Load the scan cache; returns an empty cache on any error/missing file. */
65
+ export function loadScanCache(): ScanCache {
66
+ const empty: ScanCache = {
67
+ version: CACHE_VERSION,
68
+ pricesKey: "",
69
+ excludesKey: "",
70
+ sessions: {},
71
+ };
72
+ const path = cachePath();
73
+ if (!existsSync(path)) return empty;
74
+ try {
75
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as ScanCache;
76
+ if (parsed.version !== CACHE_VERSION || typeof parsed.sessions !== "object") {
77
+ return empty;
78
+ }
79
+ return {
80
+ version: CACHE_VERSION,
81
+ pricesKey: parsed.pricesKey ?? "",
82
+ excludesKey: parsed.excludesKey ?? "",
83
+ sessions: parsed.sessions ?? {},
84
+ };
85
+ } catch (err) {
86
+ console.error(`[usage] Failed to read scan cache: ${err}`);
87
+ return empty;
88
+ }
89
+ }
90
+
91
+ /** Persist the scan cache. Never throws. */
92
+ export function saveScanCache(cache: ScanCache): void {
93
+ const path = cachePath();
94
+ try {
95
+ mkdirSync(dirname(path), { recursive: true });
96
+ writeFileSync(path, JSON.stringify(cache), "utf8");
97
+ } catch (err) {
98
+ console.error(`[usage] Failed to write scan cache: ${err}`);
99
+ }
100
+ }
package/src/config.ts ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Persistent configuration for the usage extension.
3
+ *
4
+ * Stored at ~/.pi/agent/usage.json so it survives restarts and is easy to
5
+ * edit by hand. Limits are expressed in USD; omit or set to 0 to disable a
6
+ * quota bar (the panel then shows raw spend without a limit).
7
+ */
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
11
+ import { DEFAULT_MODEL_PRICES } from "./prices.ts";
12
+
13
+ /**
14
+ * Manual price for a model, in USD per **million tokens**. Used to compute a
15
+ * cost for token-priced / proxied providers that pi records with cost 0
16
+ * (e.g. zai/GLM, 9Router `kr/…`, `cx/…`). Omitted fields count as 0.
17
+ */
18
+ export interface ModelPrice {
19
+ input?: number;
20
+ output?: number;
21
+ cacheRead?: number;
22
+ cacheWrite?: number;
23
+ }
24
+
25
+ export interface UsageConfig {
26
+ /** USD budget for the rolling 5-hour window. 0/undefined disables the bar. */
27
+ fiveHourLimit?: number;
28
+ /** USD budget for the rolling 7-day (weekly) window. 0/undefined disables the bar. */
29
+ weeklyLimit?: number;
30
+ /** Token budget for the rolling 5-hour window (for token-priced providers like zai/GLM). */
31
+ fiveHourTokenLimit?: number;
32
+ /** Token budget for the rolling 7-day (weekly) window. */
33
+ weeklyTokenLimit?: number;
34
+ /** When true, show a compact one-line usage summary widget above the editor. */
35
+ showWidget?: boolean;
36
+ /** Project cwd prefixes to exclude from aggregation (e.g. throwaway dirs). */
37
+ excludeProjects?: string[];
38
+ /** Maximum number of session files to scan (safety cap for huge histories). */
39
+ maxSessions?: number;
40
+ /**
41
+ * Manual per-model prices ($/million tokens) used to fill in cost when pi
42
+ * recorded none. Keyed by model ID; an entry keyed by the base name (without
43
+ * a proxy prefix like `kr/`) matches all proxied variants.
44
+ */
45
+ modelPrices?: Record<string, ModelPrice>;
46
+ }
47
+
48
+ const DEFAULTS: UsageConfig = {
49
+ fiveHourLimit: 0,
50
+ weeklyLimit: 0,
51
+ fiveHourTokenLimit: 0,
52
+ weeklyTokenLimit: 0,
53
+ showWidget: false,
54
+ excludeProjects: [],
55
+ maxSessions: 1000,
56
+ modelPrices: {},
57
+ };
58
+
59
+ function configPath(): string {
60
+ return join(getAgentDir(), "usage.json");
61
+ }
62
+
63
+ /** Load config, merged with defaults. Never throws — returns defaults on error. */
64
+ export function loadConfig(): UsageConfig {
65
+ const path = configPath();
66
+ if (!existsSync(path)) {
67
+ return { ...DEFAULTS, modelPrices: { ...DEFAULT_MODEL_PRICES } };
68
+ }
69
+ try {
70
+ const raw = readFileSync(path, "utf8");
71
+ const parsed = JSON.parse(raw) as Partial<UsageConfig>;
72
+ // Bundled prices are defaults; the user's modelPrices override per-key.
73
+ return {
74
+ ...DEFAULTS,
75
+ ...parsed,
76
+ modelPrices: { ...DEFAULT_MODEL_PRICES, ...(parsed.modelPrices ?? {}) },
77
+ };
78
+ } catch (err) {
79
+ console.error(`[usage] Failed to read ${path}: ${err}`);
80
+ return { ...DEFAULTS, modelPrices: { ...DEFAULT_MODEL_PRICES } };
81
+ }
82
+ }
83
+
84
+ /** Persist config to disk. Creates the agent dir if needed. */
85
+ export function saveConfig(config: UsageConfig): void {
86
+ const path = configPath();
87
+ try {
88
+ mkdirSync(dirname(path), { recursive: true });
89
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, "utf8");
90
+ } catch (err) {
91
+ console.error(`[usage] Failed to write ${path}: ${err}`);
92
+ }
93
+ }
package/src/format.ts ADDED
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Number, currency, and progress-bar formatting helpers for the usage panel.
3
+ *
4
+ * Kept dependency-free so it is easy to unit-test and reuse.
5
+ */
6
+
7
+ /** Format a token count with k/M suffixes. */
8
+ export function formatTokens(n: number): string {
9
+ if (!Number.isFinite(n) || n <= 0) return "0";
10
+ if (n < 1000) return `${Math.round(n)}`;
11
+ if (n < 1_000_000) return `${trim(n / 1000)}k`;
12
+ return `${trim(n / 1_000_000)}M`;
13
+ }
14
+
15
+ /** Format a USD cost. Small amounts get more precision. */
16
+ export function formatCost(n: number): string {
17
+ if (!Number.isFinite(n) || n <= 0) return "$0.00";
18
+ if (n < 0.01) return `$${n.toFixed(4)}`;
19
+ if (n < 1) return `$${n.toFixed(3)}`;
20
+ return `$${n.toFixed(2)}`;
21
+ }
22
+
23
+ /** Percentage of `part` relative to `total`, as a rounded integer string. */
24
+ export function percent(part: number, total: number): string {
25
+ if (total <= 0) return "0%";
26
+ return `${Math.round((part / total) * 100)}%`;
27
+ }
28
+
29
+ /** Render a horizontal progress bar. Returns the bar string (without ANSI). */
30
+ export function bar(ratio: number, width: number): string {
31
+ const w = Math.max(0, Math.floor(width));
32
+ const filled = Math.max(0, Math.min(w, Math.round(Math.max(0, Math.min(1, ratio)) * w)));
33
+ return "█".repeat(filled) + "░".repeat(w - filled);
34
+ }
35
+
36
+ /** Shorten an absolute path to a friendly project label (~/... style). */
37
+ export function shortenPath(p: string, home: string): string {
38
+ if (!p) return "(unknown)";
39
+ let path = p.replace(/\\/g, "/");
40
+ const homeN = home.replace(/\\/g, "/");
41
+ if (homeN && path.toLowerCase().startsWith(homeN.toLowerCase())) {
42
+ path = `~${path.slice(homeN.length)}`;
43
+ }
44
+ // Show last two segments for very long paths.
45
+ const parts = path.split("/").filter(Boolean);
46
+ if (parts.length > 3) return parts.slice(-2).join("/");
47
+ return path || "(unknown)";
48
+ }
49
+
50
+ /**
51
+ * Derive a human-friendly plugin label from a resource's SourceInfo.
52
+ *
53
+ * Priority:
54
+ * 1. Package sources (npm:/git:/github:) → package name (ref stripped)
55
+ * 2. Local/auto skills → the `/skills/<group>/` segment from the path, so
56
+ * e.g. `~/.claude/skills/bmad/core/bmad-master/SKILL.md` → "bmad" and
57
+ * `~/.pi/agent/skills/frontend-design/SKILL.md` → "frontend-design".
58
+ * 3. Local extensions → last baseDir segment (skipping generic names)
59
+ * 4. fallback "other"
60
+ */
61
+ export function sourceLabel(sourceInfo: {
62
+ source?: string;
63
+ baseDir?: string;
64
+ path?: string;
65
+ }): string {
66
+ const { source, baseDir, path } = sourceInfo;
67
+
68
+ // 1. Package sources → package name.
69
+ if (source && /^(npm|git|github|file):/.test(source)) {
70
+ let s = source;
71
+ for (const prefix of ["npm:", "git:", "github:", "file:"]) {
72
+ if (s.startsWith(prefix)) s = s.slice(prefix.length);
73
+ }
74
+ s = s.replace(/@[^/]+$/, ""); // strip @version/@sha/@branch
75
+ const parts = s.split("/");
76
+ return parts[parts.length - 1] || s;
77
+ }
78
+
79
+ // 2. Local/auto skills: group by the segment right after "/skills/".
80
+ if (path) {
81
+ const p = path.replace(/\\/g, "/");
82
+ const m = p.match(/\/skills\/([^/]+)\//);
83
+ if (m?.[1]) return m[1];
84
+ }
85
+
86
+ // 3. Local extensions: last meaningful baseDir segment.
87
+ if (baseDir) {
88
+ const base = baseDir.replace(/\\/g, "/").replace(/\/$/, "").split("/").pop();
89
+ if (base && base !== "extensions" && base !== "skills" && base !== "agent") {
90
+ return base;
91
+ }
92
+ }
93
+
94
+ return "other";
95
+ }
96
+
97
+ function trim(n: number): string {
98
+ return (Math.round(n * 10) / 10).toString();
99
+ }
100
+
101
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
102
+ const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
103
+
104
+ /** Human day label from a `YYYY-MM-DD` key, e.g. "Mon Jun 17". */
105
+ export function formatDayLabel(dateKey: string): string {
106
+ const [y, m, d] = dateKey.split("-").map((n) => Number.parseInt(n, 10));
107
+ if (!y || !m || !d) return dateKey;
108
+ const date = new Date(y, m - 1, d);
109
+ return `${WEEKDAYS[date.getDay()]} ${MONTHS[m - 1]} ${`${d}`.padStart(2, "0")}`;
110
+ }
111
+
112
+ /** Short month name for a 1-based month index (1 = Jan). */
113
+ export function monthLabel(month1: number): string {
114
+ return MONTHS[(month1 - 1 + 12) % 12] ?? "";
115
+ }
116
+
117
+ const SPARK_CHARS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
118
+
119
+ /** Render a unicode sparkline for a series of non-negative values. */
120
+ export function sparkline(values: number[]): string {
121
+ if (values.length === 0) return "";
122
+ const max = Math.max(...values);
123
+ if (max <= 0) return SPARK_CHARS[0].repeat(values.length);
124
+ return values
125
+ .map((v) => {
126
+ if (v <= 0) return " ";
127
+ const idx = Math.min(
128
+ SPARK_CHARS.length - 1,
129
+ Math.max(0, Math.round((v / max) * (SPARK_CHARS.length - 1))),
130
+ );
131
+ return SPARK_CHARS[idx];
132
+ })
133
+ .join("");
134
+ }
135
+
136
+ /** Heatmap glyphs per intensity level (0 = empty). */
137
+ export const HEAT_CHARS = ["·", "▪", "▩", "▣", "█"] as const;
138
+
139
+ /** Format an hour-of-day (0-23) as a friendly 12-hour label, e.g. "2pm". */
140
+ export function formatHour(h: number): string {
141
+ const hour = ((h % 24) + 24) % 24;
142
+ if (hour === 0) return "12am";
143
+ if (hour === 12) return "12pm";
144
+ return hour < 12 ? `${hour}am` : `${hour - 12}pm`;
145
+ }
146
+
147
+ /** Format an integer with thousands separators (e.g. 24086 → "24,086"). */
148
+ export function formatInt(n: number): string {
149
+ if (!Number.isFinite(n)) return "0";
150
+ return Math.round(n).toLocaleString("en-US");
151
+ }
152
+
153
+ /** Format a duration in ms as a compact human string (e.g. "2h 14m"). */
154
+ export function formatDuration(ms: number): string {
155
+ if (!Number.isFinite(ms) || ms <= 0) return "—";
156
+ const s = Math.round(ms / 1000);
157
+ if (s < 60) return `${s}s`;
158
+ const m = Math.floor(s / 60);
159
+ if (m < 60) return `${m}m`;
160
+ const h = Math.floor(m / 60);
161
+ const rm = m % 60;
162
+ if (h < 24) return rm ? `${h}h ${rm}m` : `${h}h`;
163
+ const d = Math.floor(h / 24);
164
+ const rh = h % 24;
165
+ return rh ? `${d}d ${rh}h` : `${d}d`;
166
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * In-memory report-cache freshness decision.
3
+ *
4
+ * The usage panel reuses a scanned `Report` for up to `CACHE_TTL` to avoid
5
+ * re-reading hundreds of session files on every `/usage` open. That TTL is
6
+ * purely time-based — which is exactly the bug behind the trend graph looking
7
+ * frozen ("always the same"): a user who opens the panel, spends more tokens,
8
+ * and reopens within the TTL window gets the *same* stale snapshot back, even
9
+ * though the live session file on disk already has the new turns.
10
+ *
11
+ * The fix is to also invalidate the cache whenever a new assistant turn has
12
+ * landed since the cache was built. Because pi flushes each turn to disk in
13
+ * realtime, a fresh scan will then pick those turns up, so the trend (and every
14
+ * other panel view) reflects current usage instead of a snapshot from up to
15
+ * two minutes ago.
16
+ *
17
+ * This module is intentionally dependency-free so the decision is unit-testable
18
+ * in isolation.
19
+ */
20
+
21
+ /** A cache entry carrying the epoch-ms timestamp it was built at. */
22
+ export interface ReportCacheStamp {
23
+ at: number;
24
+ }
25
+
26
+ /**
27
+ * Is the in-memory report cache still fresh enough to reuse without rescanning?
28
+ *
29
+ * Fresh only when ALL of:
30
+ * 1. a cache exists,
31
+ * 2. we're still inside the TTL window, and
32
+ * 3. no assistant turn has arrived after the cache was built.
33
+ *
34
+ * Condition (3) is the realtime fix: a new turn means the on-disk session file
35
+ * changed, so the cached report is stale regardless of the TTL window.
36
+ *
37
+ * @param cached The current in-memory cache stamp (or null when none yet).
38
+ * @param now Current epoch-ms.
39
+ * @param lastTurnAt Epoch-ms of the most recent assistant turn seen this pi run,
40
+ * or 0 when no turn has been observed.
41
+ * @param ttlMs Max age of a cache entry before it is considered stale.
42
+ */
43
+ export function isReportCacheFresh(
44
+ cached: ReportCacheStamp | null,
45
+ now: number,
46
+ lastTurnAt: number,
47
+ ttlMs: number,
48
+ ): boolean {
49
+ if (!cached) return false;
50
+ if (now - cached.at >= ttlMs) return false;
51
+ // A turn that arrived after the cache was built invalidates it, regardless
52
+ // of the TTL window, so the next open re-reads the (fresh) session files.
53
+ if (lastTurnAt > cached.at) return false;
54
+ return true;
55
+ }