@pify/usage 0.7.2 → 0.8.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 CHANGED
@@ -62,6 +62,10 @@ Computed entirely from what pi already holds: the assembled system prompt, the f
62
62
 
63
63
  Context files and skills are counted only when their text is genuinely embedded in the prompt, and the system-prompt row is the remainder after subtracting them, so the rows sum to the whole instead of double-counting. When the provider reports more than can be attributed, the difference is shown as **Other** rather than quietly dropped.
64
64
 
65
+ The "used" figure and the footer gauge are reconciled across three signals — pi's own `getContextUsage()` reading, the provider's last-request report, and the local content estimate — so a backend that reports a wrong number (a cumulative or cache-inflated total, or an implausibly small one) can't throw the gauge off: the provider's report is used when it agrees with pi's percent×window, pi's reading is trusted when they diverge beyond tolerance, and the figure is never allowed below the tokens visibly in context. (The reconciliation rule is from minuque/pi-cc-extensions.)
66
+
67
+ Reasoning gets its own **Thinking** row rather than hiding inside Conversation — on keep-thinking models it is a large, otherwise-invisible share (the opaque signature bytes are never counted or stored). And when pi's auto-compaction is on, the tokens it holds back appear as a **Compaction reserve** row and are subtracted from **Free space**, so the headroom shown is what you can actually use before compaction fires.
68
+
65
69
  ## `/usage quota`
66
70
 
67
71
  The one command here that touches the network, and only when you run it:
@@ -20,6 +20,7 @@
20
20
  import {
21
21
  formatSkillsForPrompt,
22
22
  getAgentDir,
23
+ SettingsManager,
23
24
  type ExtensionAPI,
24
25
  type ExtensionContext,
25
26
  } from "@earendil-works/pi-coding-agent";
@@ -28,7 +29,7 @@ import { join } from "node:path";
28
29
  import { readFileSync } from "node:fs";
29
30
 
30
31
  import { addRecord, aggregate, recordFromEntry, windowTotals } from "../src/aggregate.ts";
31
- import { buildBreakdown, formatBreakdown } from "../src/context.ts";
32
+ import { buildBreakdown, formatBreakdown, resolveUsedTokens } from "../src/context.ts";
32
33
  import { contextGauge, footerText, formatCost, formatTokens, historyBlock, sessionBlock } from "../src/format.ts";
33
34
  import { QUOTA_PROVIDERS, fetchQuota, quotaReport, type QuotaResult } from "../src/quota.ts";
34
35
  import { redact } from "../src/redact.ts";
@@ -72,9 +73,20 @@ export default function usage(pi: ExtensionAPI) {
72
73
  }
73
74
 
74
75
  function contextPct(ctx: UiContext): number | null {
75
- const window = (ctx.model as { contextWindow?: number } | null)?.contextWindow;
76
- if (!window || lastPromptTokens === 0) return null;
77
- return Math.min(100, (lastPromptTokens / window) * 100);
76
+ const usage = (
77
+ ctx as { getContextUsage?: () => { tokens?: number | null; contextWindow?: number; percent?: number | null } | undefined }
78
+ ).getContextUsage?.();
79
+ const window = usage?.contextWindow ?? (ctx.model as { contextWindow?: number } | null)?.contextWindow ?? 0;
80
+ // Reconcile pi's own reading with the provider's last-request report, rather
81
+ // than trusting the provider number blind (some backends report it wrong).
82
+ const used = resolveUsedTokens({
83
+ hostTokens: usage?.tokens ?? null,
84
+ hostPercent: usage?.percent ?? null,
85
+ window,
86
+ providerTokens: lastPromptTokens || null,
87
+ });
88
+ if (used === null || window <= 0) return null;
89
+ return Math.min(100, (used / window) * 100);
78
90
  }
79
91
 
80
92
  function dashboard(ctx: UiContext): string {
@@ -150,6 +162,20 @@ export default function usage(pi: ExtensionAPI) {
150
162
  * it, the enabled tool definitions, and the entries that would be sent.
151
163
  * No network, no model call — same rule as the rest of the package.
152
164
  */
165
+ /**
166
+ * Tokens pi holds back for auto-compaction, so "Free space" reflects real
167
+ * headroom. Best-effort: 0 when compaction is off or the API is unavailable.
168
+ */
169
+ function compactionReserve(ctx: UiContext): number {
170
+ try {
171
+ const projectTrusted = (ctx as { isProjectTrusted?: () => boolean }).isProjectTrusted?.() ?? false;
172
+ const settings = SettingsManager.create(ctx.cwd, getAgentDir(), { projectTrusted });
173
+ return settings.getCompactionEnabled() ? Math.max(0, settings.getCompactionReserveTokens()) : 0;
174
+ } catch {
175
+ return 0;
176
+ }
177
+ }
178
+
153
179
  function contextBreakdown(ctx: UiContext): string {
154
180
  const host = ctx as unknown as {
155
181
  getSystemPrompt?: () => string;
@@ -158,7 +184,7 @@ export default function usage(pi: ExtensionAPI) {
158
184
  skills?: unknown[];
159
185
  selectedTools?: string[];
160
186
  };
161
- getContextUsage?: () => { contextWindow?: number; used?: number; total?: number } | undefined;
187
+ getContextUsage?: () => { tokens?: number | null; contextWindow?: number; percent?: number | null } | undefined;
162
188
  sessionManager?: { buildContextEntries?: () => unknown[]; getBranch?: () => unknown[] };
163
189
  };
164
190
 
@@ -187,8 +213,19 @@ export default function usage(pi: ExtensionAPI) {
187
213
  tools,
188
214
  entries,
189
215
  contextWindow,
216
+ reserveTokens: compactionReserve(ctx),
217
+ });
218
+ // The provider's real last-request number is the primary "used" figure (it
219
+ // makes the "Other" row — what the provider counts that we can't attribute —
220
+ // meaningful), reconciled against pi's own reading and floored by the local
221
+ // estimate so a wrong provider total can't over- or under-state the window.
222
+ const reported = resolveUsedTokens({
223
+ hostTokens: usage?.tokens ?? null,
224
+ hostPercent: usage?.percent ?? null,
225
+ window: contextWindow,
226
+ providerTokens: lastPromptTokens || null,
227
+ estimate: breakdown.attributed,
190
228
  });
191
- const reported = typeof usage?.used === "number" ? usage.used : lastPromptTokens || null;
192
229
  return formatBreakdown(breakdown, reported);
193
230
  }
194
231
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/usage",
3
- "version": "0.7.2",
3
+ "version": "0.8.1",
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",
package/src/context.ts CHANGED
@@ -26,6 +26,8 @@ export interface ContextBreakdown {
26
26
  /** Sum of every attributed part. */
27
27
  attributed: number;
28
28
  contextWindow: number;
29
+ /** Tokens pi holds back for auto-compaction; free space excludes them. */
30
+ reserveTokens: number;
29
31
  }
30
32
 
31
33
  /** pi's own compaction estimate: four characters per token. */
@@ -52,6 +54,8 @@ export interface BreakdownInput {
52
54
  /** Entries pi would send as conversation this turn. */
53
55
  entries: unknown[];
54
56
  contextWindow: number;
57
+ /** Tokens pi reserves for auto-compaction (0 when compaction is off). */
58
+ reserveTokens?: number;
55
59
  }
56
60
 
57
61
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -63,9 +67,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
63
67
  * Tool results are the part that grows without anyone deciding it should,
64
68
  * which is exactly why they deserve their own row.
65
69
  */
66
- function foldEntries(entries: unknown[]): { conversation: number; toolResults: number } {
70
+ function foldEntries(entries: unknown[]): { conversation: number; toolResults: number; thinking: number } {
67
71
  let conversation = 0;
68
72
  let toolResults = 0;
73
+ let thinking = 0;
69
74
 
70
75
  for (const entry of entries) {
71
76
  if (!isRecord(entry)) continue;
@@ -84,7 +89,10 @@ function foldEntries(entries: unknown[]): { conversation: number; toolResults: n
84
89
  } else if (block.type === "text") {
85
90
  conversation += estimateTextTokens(block.text);
86
91
  } else if (block.type === "thinking") {
87
- conversation += estimateTextTokens(block.thinking);
92
+ // Reasoning tokens are their own line — on keep-thinking models they
93
+ // are a large, invisible share otherwise buried in "Conversation".
94
+ // The opaque signature bytes are deliberately never counted or kept.
95
+ thinking += estimateTextTokens(block.thinking);
88
96
  }
89
97
  }
90
98
  continue;
@@ -100,7 +108,7 @@ function foldEntries(entries: unknown[]): { conversation: number; toolResults: n
100
108
  if (role) conversation += estimateTextTokens(message.content ?? message);
101
109
  }
102
110
 
103
- return { conversation, toolResults };
111
+ return { conversation, toolResults, thinking };
104
112
  }
105
113
 
106
114
  export function buildBreakdown(input: BreakdownInput): ContextBreakdown {
@@ -119,7 +127,7 @@ export function buildBreakdown(input: BreakdownInput): ContextBreakdown {
119
127
  }),
120
128
  0,
121
129
  );
122
- const { conversation, toolResults } = foldEntries(input.entries);
130
+ const { conversation, toolResults, thinking } = foldEntries(input.entries);
123
131
  // Memory and skills live inside the prompt; subtract so rows do not overlap.
124
132
  const system = Math.max(0, estimateTextTokens(input.systemPrompt) - memory - skills);
125
133
 
@@ -129,6 +137,7 @@ export function buildBreakdown(input: BreakdownInput): ContextBreakdown {
129
137
  { label: "Skills", tokens: skills },
130
138
  { label: "Tool definitions", tokens: tools },
131
139
  { label: "Tool results", tokens: toolResults },
140
+ { label: "Thinking", tokens: thinking },
132
141
  { label: "Conversation", tokens: conversation },
133
142
  ];
134
143
 
@@ -136,9 +145,62 @@ export function buildBreakdown(input: BreakdownInput): ContextBreakdown {
136
145
  parts,
137
146
  attributed: parts.reduce((sum, part) => sum + part.tokens, 0),
138
147
  contextWindow: Math.max(0, input.contextWindow),
148
+ reserveTokens: Math.max(0, input.reserveTokens ?? 0),
139
149
  };
140
150
  }
141
151
 
152
+ export interface UsedTokensInput {
153
+ /** pi's own estimate of context tokens (getContextUsage().tokens), or null. */
154
+ hostTokens: number | null;
155
+ /** pi's own percent-of-window (getContextUsage().percent), or null. */
156
+ hostPercent: number | null;
157
+ /** The context window; 0 when unknown. */
158
+ window: number;
159
+ /** The provider's report for the last request (input + cacheRead), or null. */
160
+ providerTokens: number | null;
161
+ /** A local content estimate (a hard lower bound), when one is available. */
162
+ estimate?: number;
163
+ }
164
+
165
+ /**
166
+ * Reconcile the several "how full is the window" signals into one used-tokens
167
+ * figure, so the gauge does not blindly trust a provider number that some
168
+ * backends report wrong (cumulative totals, cache-inflated counts, or an
169
+ * implausibly small figure). The rule — from minuque/pi-cc-extensions'
170
+ * resolveUsedTokens:
171
+ *
172
+ * - the provider's report is the primary signal (it is the real last request),
173
+ * - but when it diverges beyond tolerance from pi's own percent×window reading
174
+ * (what pi itself uses for the gauge and for compaction), trust pi's number,
175
+ * - and never report fewer tokens than the content visibly in context.
176
+ *
177
+ * Returns null only when nothing is known (so the gauge can hide itself).
178
+ */
179
+ export function resolveUsedTokens(input: UsedTokensInput): number | null {
180
+ const w = input.window > 0 ? input.window : 0;
181
+ const fromPercent = input.hostPercent !== null && w > 0 ? (input.hostPercent / 100) * w : null;
182
+ const host = input.hostTokens !== null ? input.hostTokens : fromPercent;
183
+ const provider = input.providerTokens !== null && input.providerTokens > 0 ? input.providerTokens : null;
184
+ const estimate = typeof input.estimate === "number" && input.estimate > 0 ? input.estimate : null;
185
+
186
+ let used = provider ?? host ?? estimate;
187
+ if (used === null || used === undefined) return null;
188
+
189
+ // Trust pi's own reading when the provider's number diverges beyond tolerance
190
+ // (25% of the host reading, or 2k tokens, whichever is larger).
191
+ if (provider !== null && host !== null) {
192
+ const tolerance = Math.max(2000, host * 0.25);
193
+ if (Math.abs(provider - host) > tolerance) used = host;
194
+ }
195
+
196
+ // A content estimate is a hard floor: you cannot be using fewer tokens than
197
+ // the text that is demonstrably in context.
198
+ if (estimate !== null && estimate > used) used = estimate;
199
+
200
+ if (w > 0) used = Math.min(used, w);
201
+ return Math.max(0, Math.round(used));
202
+ }
203
+
142
204
  const BAR_WIDTH = 28;
143
205
 
144
206
  function bar(fraction: number): string {
@@ -168,10 +230,15 @@ export function formatBreakdown(breakdown: ContextBreakdown, reportedUsed: numbe
168
230
  const window = breakdown.contextWindow;
169
231
  const used = Math.max(reportedUsed ?? 0, breakdown.attributed);
170
232
  const other = Math.max(0, used - breakdown.attributed);
171
- const free = window > 0 ? Math.max(0, window - used) : 0;
233
+ // pi holds back a reserve for auto-compaction; real headroom excludes it, so
234
+ // show it as its own row and subtract it from free space rather than letting
235
+ // "Free space" overstate what you can actually use before compaction fires.
236
+ const reserve = window > 0 ? Math.min(breakdown.reserveTokens, Math.max(0, window - used)) : 0;
237
+ const free = window > 0 ? Math.max(0, window - used - reserve) : 0;
172
238
 
173
239
  const rows = [...breakdown.parts];
174
240
  if (other > 0) rows.push({ label: "Other", tokens: other });
241
+ if (reserve > 0) rows.push({ label: "Compaction reserve", tokens: reserve });
175
242
  if (window > 0) rows.push({ label: "Free space", tokens: free });
176
243
 
177
244
  const width = rows.reduce((m, row) => Math.max(m, row.label.length), 0);