pi-context-view 0.2.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/usage.ts ADDED
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Pure context-usage classification: combine the frozen Initial snapshot's
3
+ * prompt/tool decomposition with the live session messages into estimated
4
+ * category totals. No pi API access — unit-testable.
5
+ */
6
+ import { type ContextEvent, type ContextUsage, estimateTokens } from "@earendil-works/pi-coding-agent";
7
+
8
+ import type {
9
+ ContextUsageSnapshot,
10
+ InitialSnapshot,
11
+ InjectionItem,
12
+ ReportedContextUsage,
13
+ UsageCategory,
14
+ UsagePreviewEntry,
15
+ } from "./model.ts";
16
+
17
+ /** Everything computeUsage needs; messages must already be synthetic-filtered. */
18
+ export interface UsageInputs {
19
+ snapshot: InitialSnapshot;
20
+ messages: ContextEvent["messages"];
21
+ reported?: ReportedContextUsage;
22
+ modelLabel?: string;
23
+ computedAt?: Date;
24
+ }
25
+
26
+ /**
27
+ * Estimate the current/next-request context composition. Prompt and tool
28
+ * categories come from the frozen Initial snapshot; message categories are
29
+ * classified from the live session context. Empty categories are dropped and
30
+ * every aggregate equals the exact sum of its children.
31
+ */
32
+ export function computeUsage(inputs: UsageInputs): ContextUsageSnapshot {
33
+ const categories = [
34
+ ...classifyPromptCategories(inputs.snapshot),
35
+ ...classifyMessages(inputs.messages, contextOnlyMessages(inputs.snapshot)),
36
+ ].filter((category) => category.tokens > 0);
37
+ return {
38
+ computedAt: inputs.computedAt ?? new Date(),
39
+ modelLabel: inputs.modelLabel,
40
+ reported: inputs.reported,
41
+ categories,
42
+ estimatedTokens: categories.reduce((sum, category) => sum + category.tokens, 0),
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Flatten one category's preview entries across its breakdown, chronologically
48
+ * when every entry is message-backed. Raw entry text is process-local; the
49
+ * caller must sanitize before rendering and never log or persist it.
50
+ */
51
+ export function collectPreviewEntries(category: UsageCategory): UsagePreviewEntry[] {
52
+ const entries: UsagePreviewEntry[] = [];
53
+ const visit = (node: UsageCategory): void => {
54
+ entries.push(...(node.entries ?? []));
55
+ for (const child of node.children ?? []) visit(child);
56
+ };
57
+ visit(category);
58
+ if (entries.length > 1 && entries.every((entry) => entry.timestamp !== undefined)) {
59
+ entries.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0));
60
+ }
61
+ return entries;
62
+ }
63
+
64
+ /** Convert pi's nullable ContextUsage to the undefined-based model shape. */
65
+ export function toReportedUsage(usage: ContextUsage | undefined): ReportedContextUsage | undefined {
66
+ if (usage === undefined) return undefined;
67
+ return {
68
+ tokens: usage.tokens ?? undefined,
69
+ contextWindow: usage.contextWindow,
70
+ percent: usage.percent ?? undefined,
71
+ };
72
+ }
73
+
74
+ /** Map frozen snapshot items to prompt/tool/memory/skill categories. */
75
+ function classifyPromptCategories(snapshot: InitialSnapshot): UsageCategory[] {
76
+ const systemPrompt: UsageCategory[] = [];
77
+ const systemTools: UsageCategory[] = [];
78
+ const customTools: UsageCategory[] = [];
79
+ const mcpTools: UsageCategory[] = [];
80
+ const contextFiles: UsageCategory[] = [];
81
+ const skills: UsageCategory[] = [];
82
+ for (const group of snapshot.groups) {
83
+ for (const item of group.items) {
84
+ switch (item.kind) {
85
+ case "base-prompt":
86
+ case "append-prompt":
87
+ case "prompt-addition":
88
+ systemPrompt.push(leafFromItem(item));
89
+ break;
90
+ case "tool":
91
+ if (item.source.native) systemTools.push(...breakdownFromItem(item));
92
+ else if (isMcpTool(item)) mcpTools.push(leafFromItem(item));
93
+ else customTools.push(leafFromItem(item));
94
+ break;
95
+ case "context-file":
96
+ contextFiles.push(leafFromItem(item));
97
+ break;
98
+ case "skills":
99
+ skills.push(...breakdownFromItem(item));
100
+ break;
101
+ case "message":
102
+ // Initial custom messages live in the session; classifyMessages counts them.
103
+ break;
104
+ }
105
+ }
106
+ }
107
+ return withoutEmpty([
108
+ aggregate("system-prompt", "System Prompt", systemPrompt),
109
+ aggregate("system-tools", "System Tools", systemTools),
110
+ aggregate("custom-tools", "Custom Tools", customTools),
111
+ aggregate("mcp-tools", "MCP Tools", mcpTools),
112
+ aggregate("context-files", "Memory (AGENTS.md)", contextFiles),
113
+ aggregate("skills", "Skills", skills),
114
+ ]);
115
+ }
116
+
117
+ /** Best-effort MCP attribution from the only public provenance field available. */
118
+ function isMcpTool(item: InjectionItem): boolean {
119
+ return /(^|[^a-z])mcp([^a-z]|$)/i.test(`${item.source.id} ${item.source.label}`);
120
+ }
121
+
122
+ /** Collect frozen messages that existed only in the transformed provider context. */
123
+ function contextOnlyMessages(snapshot: InitialSnapshot): InjectionItem[] {
124
+ return snapshot.groups.flatMap((group) =>
125
+ group.items.filter((item) => item.kind === "message" && item.contextOnly === true)
126
+ );
127
+ }
128
+
129
+ /** Classify live session messages and frozen context-only injections with preview entries. */
130
+ function classifyMessages(
131
+ messages: ContextEvent["messages"],
132
+ contextOnly: readonly InjectionItem[],
133
+ ): UsageCategory[] {
134
+ const user: UsagePreviewEntry[] = [];
135
+ const agentText: UsagePreviewEntry[] = [];
136
+ const agentThinking: UsagePreviewEntry[] = [];
137
+ const agentToolCalls: UsagePreviewEntry[] = [];
138
+ const bashExecutions: UsagePreviewEntry[] = [];
139
+ const compacted: UsagePreviewEntry[] = [];
140
+ const toolResults = new Map<string, UsagePreviewEntry[]>();
141
+ const customMessages = new Map<string, UsagePreviewEntry[]>();
142
+
143
+ for (const item of contextOnly) {
144
+ appendEntry(customMessages, item.source.label, {
145
+ breadcrumb: [item.label],
146
+ tokens: item.tokens,
147
+ text: item.text,
148
+ });
149
+ }
150
+ for (const message of messages) {
151
+ switch (message.role) {
152
+ case "user":
153
+ user.push({
154
+ timestamp: message.timestamp,
155
+ breadcrumb: ["user"],
156
+ tokens: estimateTokens(message),
157
+ text: contentToText(message.content),
158
+ });
159
+ break;
160
+ case "assistant": {
161
+ const texts = message.content.flatMap((block) => (block.type === "text" ? [block.text] : []));
162
+ const thinkings = message.content.flatMap((block) =>
163
+ block.type === "thinking" ? [block.thinking] : []
164
+ );
165
+ agentText.push(...blockEntries(message.timestamp, "text", texts));
166
+ agentThinking.push(...blockEntries(message.timestamp, "thinking", thinkings));
167
+ for (const block of message.content) {
168
+ if (block.type !== "toolCall") continue;
169
+ const args = JSON.stringify(block.arguments);
170
+ agentToolCalls.push({
171
+ timestamp: message.timestamp,
172
+ breadcrumb: ["assistant", block.name],
173
+ tokens: textTokens(block.name.length + args.length),
174
+ text: `${block.name}(${args})`,
175
+ });
176
+ }
177
+ break;
178
+ }
179
+ case "toolResult":
180
+ appendEntry(toolResults, message.toolName, {
181
+ timestamp: message.timestamp,
182
+ breadcrumb: [message.toolName],
183
+ tokens: estimateTokens(message),
184
+ text: contentToText(message.content),
185
+ });
186
+ break;
187
+ case "custom":
188
+ appendEntry(customMessages, message.customType, {
189
+ timestamp: message.timestamp,
190
+ breadcrumb: [message.customType],
191
+ tokens: estimateTokens(message),
192
+ text: contentToText(message.content),
193
+ });
194
+ break;
195
+ case "bashExecution":
196
+ if (message.excludeFromContext !== true) {
197
+ bashExecutions.push({
198
+ timestamp: message.timestamp,
199
+ breadcrumb: ["bash"],
200
+ tokens: estimateTokens(message),
201
+ text: `$ ${message.command}\n${message.output}`,
202
+ });
203
+ }
204
+ break;
205
+ case "branchSummary":
206
+ compacted.push({
207
+ timestamp: message.timestamp,
208
+ breadcrumb: ["branch"],
209
+ tokens: estimateTokens(message),
210
+ text: message.summary,
211
+ });
212
+ break;
213
+ case "compactionSummary":
214
+ compacted.push({
215
+ timestamp: message.timestamp,
216
+ breadcrumb: ["compaction"],
217
+ tokens: estimateTokens(message),
218
+ text: message.summary,
219
+ });
220
+ break;
221
+ }
222
+ }
223
+
224
+ const toolOutput = aggregate("tool-output", "Tool Output", withoutEmpty([
225
+ ...leavesFromMap("tool-result", toolResults),
226
+ leaf("bash-executions", "Bash Executions", bashExecutions),
227
+ ]));
228
+ return withoutEmpty([
229
+ leaf("user-messages", "User Messages", user),
230
+ leaf("agent-text-messages", "Agent Text Messages", agentText),
231
+ leaf("agent-thinking-messages", "Agent Thinking Messages", agentThinking),
232
+ leaf("agent-tool-call-messages", "Agent Tool Call Messages", agentToolCalls),
233
+ toolOutput,
234
+ aggregate("extension-messages", "Extensions", leavesFromMap("custom-message", customMessages)),
235
+ leaf("compacted-data", "Compacted Data", compacted),
236
+ ]);
237
+ }
238
+
239
+ /** One category whose estimate is the exact sum of its preview entries. */
240
+ function leaf(id: string, label: string, entries: readonly UsagePreviewEntry[]): UsageCategory {
241
+ return {
242
+ id,
243
+ label,
244
+ tokens: entries.reduce((sum, entry) => sum + entry.tokens, 0),
245
+ entries,
246
+ };
247
+ }
248
+
249
+ /** Category carrying a snapshot item's label, estimate, and timeless content entry. */
250
+ function leafFromItem(item: InjectionItem): UsageCategory {
251
+ return {
252
+ id: `item:${item.id}`,
253
+ label: item.label,
254
+ tokens: item.tokens,
255
+ entries: [{ breadcrumb: [item.label], tokens: item.tokens, text: item.text }],
256
+ };
257
+ }
258
+
259
+ /** Per-block entries; the block-index cell appears only for multi-block messages. */
260
+ function blockEntries(timestamp: number, kind: string, texts: readonly string[]): UsagePreviewEntry[] {
261
+ return texts.map((text, index) => ({
262
+ timestamp,
263
+ breadcrumb: texts.length > 1 ? ["assistant", `${kind} ${index + 1}/${texts.length}`] : ["assistant"],
264
+ tokens: textTokens(text.length),
265
+ text,
266
+ }));
267
+ }
268
+
269
+ /** Text rendering of string-or-block message content; non-text blocks become placeholders. */
270
+ function contentToText(content: string | ReadonlyArray<{ type: string; text?: string }>): string {
271
+ if (typeof content === "string") return content;
272
+ return content
273
+ .map((block) => (block.type === "text" && block.text !== undefined ? block.text : `[${block.type}]`))
274
+ .join("\n");
275
+ }
276
+
277
+ /** Expand an aggregate snapshot item into its children, or itself when it has none. */
278
+ function breakdownFromItem(item: InjectionItem): UsageCategory[] {
279
+ if (item.children === undefined || item.children.length === 0) return [leafFromItem(item)];
280
+ return item.children.map((child) => leafFromItem(child));
281
+ }
282
+
283
+ /** Parent category whose total is the exact sum of its children; undefined when empty. */
284
+ function aggregate(id: string, label: string, children: UsageCategory[]): UsageCategory | undefined {
285
+ if (children.length === 0) return undefined;
286
+ return {
287
+ id,
288
+ label,
289
+ tokens: children.reduce((sum, child) => sum + child.tokens, 0),
290
+ children: [...children].sort((a, b) => b.tokens - a.tokens),
291
+ };
292
+ }
293
+
294
+ /** Keep only present categories with a non-zero estimate. */
295
+ function withoutEmpty(categories: Array<UsageCategory | undefined>): UsageCategory[] {
296
+ return categories.filter((category): category is UsageCategory => category !== undefined && category.tokens > 0);
297
+ }
298
+
299
+ /** Accumulate a preview entry under a map key. */
300
+ function appendEntry(
301
+ totals: Map<string, UsagePreviewEntry[]>,
302
+ key: string,
303
+ entry: UsagePreviewEntry,
304
+ ): void {
305
+ const entries = totals.get(key);
306
+ if (entries === undefined) totals.set(key, [entry]);
307
+ else entries.push(entry);
308
+ }
309
+
310
+ /** Leaves from accumulated per-key preview entries. */
311
+ function leavesFromMap(idPrefix: string, totals: Map<string, UsagePreviewEntry[]>): UsageCategory[] {
312
+ return [...totals.entries()].map(([label, entries]) => leaf(`${idPrefix}:${label}`, label, entries));
313
+ }
314
+
315
+ /** Same chars/4 heuristic pi's estimateTokens uses for text content. */
316
+ function textTokens(chars: number): number {
317
+ return Math.ceil(chars / 4);
318
+ }