@pify/usage 0.7.2 → 0.8.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
@@ -62,6 +62,8 @@ 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
+ 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.
66
+
65
67
  ## `/usage quota`
66
68
 
67
69
  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";
@@ -150,6 +151,20 @@ export default function usage(pi: ExtensionAPI) {
150
151
  * it, the enabled tool definitions, and the entries that would be sent.
151
152
  * No network, no model call — same rule as the rest of the package.
152
153
  */
154
+ /**
155
+ * Tokens pi holds back for auto-compaction, so "Free space" reflects real
156
+ * headroom. Best-effort: 0 when compaction is off or the API is unavailable.
157
+ */
158
+ function compactionReserve(ctx: UiContext): number {
159
+ try {
160
+ const projectTrusted = (ctx as { isProjectTrusted?: () => boolean }).isProjectTrusted?.() ?? false;
161
+ const settings = SettingsManager.create(ctx.cwd, getAgentDir(), { projectTrusted });
162
+ return settings.getCompactionEnabled() ? Math.max(0, settings.getCompactionReserveTokens()) : 0;
163
+ } catch {
164
+ return 0;
165
+ }
166
+ }
167
+
153
168
  function contextBreakdown(ctx: UiContext): string {
154
169
  const host = ctx as unknown as {
155
170
  getSystemPrompt?: () => string;
@@ -187,6 +202,7 @@ export default function usage(pi: ExtensionAPI) {
187
202
  tools,
188
203
  entries,
189
204
  contextWindow,
205
+ reserveTokens: compactionReserve(ctx),
190
206
  });
191
207
  const reported = typeof usage?.used === "number" ? usage.used : lastPromptTokens || null;
192
208
  return formatBreakdown(breakdown, reported);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/usage",
3
- "version": "0.7.2",
3
+ "version": "0.8.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",
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,6 +145,7 @@ 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
 
@@ -168,10 +178,15 @@ export function formatBreakdown(breakdown: ContextBreakdown, reportedUsed: numbe
168
178
  const window = breakdown.contextWindow;
169
179
  const used = Math.max(reportedUsed ?? 0, breakdown.attributed);
170
180
  const other = Math.max(0, used - breakdown.attributed);
171
- const free = window > 0 ? Math.max(0, window - used) : 0;
181
+ // pi holds back a reserve for auto-compaction; real headroom excludes it, so
182
+ // show it as its own row and subtract it from free space rather than letting
183
+ // "Free space" overstate what you can actually use before compaction fires.
184
+ const reserve = window > 0 ? Math.min(breakdown.reserveTokens, Math.max(0, window - used)) : 0;
185
+ const free = window > 0 ? Math.max(0, window - used - reserve) : 0;
172
186
 
173
187
  const rows = [...breakdown.parts];
174
188
  if (other > 0) rows.push({ label: "Other", tokens: other });
189
+ if (reserve > 0) rows.push({ label: "Compaction reserve", tokens: reserve });
175
190
  if (window > 0) rows.push({ label: "Free space", tokens: free });
176
191
 
177
192
  const width = rows.reduce((m, row) => Math.max(m, row.label.length), 0);