pi-context-map 0.6.2 → 0.7.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/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.0] - 2026-06-15
4
+ ### Bug Fixes
5
+ - **Fixed token accuracy**: Now uses Pi's actual token count from `ctx.getContextUsage()` instead of heuristic estimation. The usage percentage now matches Pi's terminal display.
6
+ - **Fixed compaction summary detection**: Now detects `role: "compactionSummary"` (Pi's actual format) with the `summary` field. Summaries no longer show 0%.
7
+ - **Fixed file status calculation**: Changed from turn-based to position-based. Files in the last 30% of messages are "active", middle 40% are "stale", first 30% are "legacy".
8
+ - **Removed error spam**: Silent error handling instead of console.error for non-critical failures.
9
+
10
+ ### Features
11
+ - **Auto-open browser**: Report automatically opens in default browser on first `/context-map` invocation.
12
+ - **Pi actual tokens in HTML**: Generator accepts and displays Pi's real token count.
13
+
3
14
  ## [0.6.2] - 2026-06-15
4
15
  ### Bug Fixes
5
16
  - **Fixed Pi message format**: Now uses `type: "toolCall"` (not `"tool_use"`) and `toolCallId` (not `tool_call_id`) to match Pi's actual `@mariozechner/pi-ai` types.
@@ -3,13 +3,16 @@
3
3
  * Parses Pi session messages to identify the active working set of files,
4
4
  * their token weights, and their temporal status.
5
5
  *
6
- * Pi message format (from @mariozechner/pi-ai):
7
- * UserMessage: { role: "user", content: string | (TextContent | ImageContent)[] }
8
- * AssistantMessage: { role: "assistant", content: (TextContent | ThinkingContent | ToolCall)[] }
9
- * ToolResultMessage: { role: "toolResult", toolCallId, toolName, content: (TextContent | ImageContent)[] }
6
+ * Pi message format (from @mariozechner/pi-ai + pi-coding-agent):
7
+ * UserMessage: { role: "user", content: string | (TextContent | ImageContent)[] }
8
+ * AssistantMessage: { role: "assistant", content: (TextContent | ThinkingContent | ToolCall)[] }
9
+ * ToolResultMessage: { role: "toolResult", toolCallId, toolName, content }
10
+ * CompactionSummaryMessage:{ role: "compactionSummary", summary: string, tokensBefore: number }
11
+ * BranchSummaryMessage: { role: "branchSummary", summary: string }
12
+ * BashExecutionMessage: { role: "bashExecution", command, output }
13
+ * CustomMessage: { role: "custom", customType, content }
10
14
  *
11
15
  * ToolCall: { type: "toolCall", id, name, arguments }
12
- * ToolCall.id maps to ToolResultMessage.toolCallId
13
16
  */
14
17
  import { TokenCounter } from "./token-counter";
15
18
 
@@ -39,6 +42,9 @@ export interface ContextComposition {
39
42
  summaries: ContextSlice;
40
43
  total: ContextSlice;
41
44
  files_detail: FileContext[];
45
+ /** Pi's actual token count from ctx.getContextUsage() — may differ from heuristic total */
46
+ actualTokens?: number | null;
47
+ actualPercent?: number | null;
42
48
  }
43
49
 
44
50
  export class ContextAnalyzer {
@@ -60,23 +66,65 @@ export class ContextAnalyzer {
60
66
  systemTokens += TokenCounter.count(systemPrompt);
61
67
  }
62
68
 
69
+ // Track message indices for status calculation
70
+ const totalMessages = messages.length;
71
+
63
72
  for (let index = 0; index < messages.length; index++) {
64
73
  const msg = messages[index];
65
74
  const turn = index + 1;
75
+
76
+ // Normalize role — Pi may use different role strings
66
77
  const role = msg.role || "";
67
78
 
68
- // 1. Compaction summaries (Pi compaction entries)
79
+ // 1. Compaction summaries (Pi uses role="compactionSummary" with summary field)
69
80
  if (
81
+ role === "compactionSummary" ||
70
82
  role === "compaction" ||
71
83
  msg.type === "compaction" ||
72
84
  msg.customType === "compaction" ||
73
85
  msg.compactionEntry
74
86
  ) {
75
- summaryTokens += TokenCounter.countMessage(msg);
87
+ // Use the summary field if available, otherwise fall back to content
88
+ const summaryText =
89
+ typeof msg.summary === "string"
90
+ ? msg.summary
91
+ : typeof msg.content === "string"
92
+ ? msg.content
93
+ : JSON.stringify(msg.content || msg);
94
+ summaryTokens += TokenCounter.count(summaryText);
95
+ continue;
96
+ }
97
+
98
+ // 2. Branch summaries
99
+ if (role === "branchSummary") {
100
+ const summaryText =
101
+ typeof msg.summary === "string" ? msg.summary : JSON.stringify(msg);
102
+ summaryTokens += TokenCounter.count(summaryText);
103
+ continue;
104
+ }
105
+
106
+ // 3. Bash executions
107
+ if (role === "bashExecution") {
108
+ toolTokens += TokenCounter.countMessage(msg);
109
+ continue;
110
+ }
111
+
112
+ // 4. Custom messages (extensions)
113
+ if (role === "custom") {
114
+ // Categorize based on customType
115
+ const customType = msg.customType || "";
116
+ if (
117
+ customType.includes("compaction") ||
118
+ customType.includes("summary")
119
+ ) {
120
+ summaryTokens += TokenCounter.countMessage(msg);
121
+ } else {
122
+ historyTokens += TokenCounter.countMessage(msg);
123
+ }
76
124
  continue;
77
125
  }
78
126
 
79
- // 2. Tool results (Pi uses role="toolResult")
127
+ // 5. Tool results (Pi uses role="toolResult")
80
128
  if (role === "toolResult") {
81
129
  toolTokens += TokenCounter.countMessage(msg);
82
130
  // Track file content from tool results
@@ -100,7 +148,10 @@ export class ContextAnalyzer {
100
148
  turn,
101
149
  timestamp: msg.timestamp || Date.now(),
102
150
  },
103
- status: this.calculateStatus(turn, currentTurn),
151
+ status: this.calculateStatus(
152
+ index,
153
+ totalMessages,
154
+ ),
104
155
  });
105
156
  }
106
157
  }
@@ -108,7 +159,7 @@ export class ContextAnalyzer {
108
159
  continue;
109
160
  }
110
161
 
111
- // 3. User messages
162
+ // 6. User messages
112
163
  if (role === "user") {
113
164
  historyTokens += TokenCounter.countMessage(msg);
114
165
  // Track file attachments (images, file paths in text)
@@ -127,7 +178,10 @@ export class ContextAnalyzer {
127
178
  turn,
128
179
  timestamp: msg.timestamp || Date.now(),
129
180
  },
130
- status: this.calculateStatus(turn, currentTurn),
181
+ status: this.calculateStatus(
182
+ index,
183
+ totalMessages,
184
+ ),
131
185
  });
132
186
  }
133
187
  }
@@ -146,7 +200,10 @@ export class ContextAnalyzer {
146
200
  turn,
147
201
  timestamp: msg.timestamp || Date.now(),
148
202
  },
149
- status: this.calculateStatus(turn, currentTurn),
203
+ status: this.calculateStatus(
204
+ index,
205
+ totalMessages,
206
+ ),
150
207
  });
151
208
  }
152
209
  }
@@ -157,7 +214,7 @@ export class ContextAnalyzer {
157
214
  continue;
158
215
  }
159
216
 
160
- // 4. Assistant messages — track toolCall blocks
217
+ // 7. Assistant messages — track toolCall blocks
161
218
  if (role === "assistant") {
162
219
  historyTokens += TokenCounter.countMessage(msg);
163
220
  if (Array.isArray(msg.content)) {
@@ -167,9 +224,15 @@ export class ContextAnalyzer {
167
224
  const p = this.extractPath(block.name, block.arguments);
168
225
  if (p) {
169
226
  const opType = this.getOpType(block.name);
170
- const result = this.findToolResult(messages, index, block.id);
227
+ const result = this.findToolResult(
228
+ messages,
229
+ index,
230
+ block.id,
231
+ );
171
232
  const content = result?.content || "";
172
- const w = TokenCounter.count(String(JSON.stringify(content)));
233
+ const w = TokenCounter.count(
234
+ String(JSON.stringify(content)),
235
+ );
173
236
  fileTokens += w;
174
237
  fileRegistry.set(p, {
175
238
  path: p,
@@ -179,7 +242,10 @@ export class ContextAnalyzer {
179
242
  turn,
180
243
  timestamp: msg.timestamp || Date.now(),
181
244
  },
182
- status: this.calculateStatus(turn, currentTurn),
245
+ status: this.calculateStatus(
246
+ index,
247
+ totalMessages,
248
+ ),
183
249
  });
184
250
  }
185
251
  }
@@ -188,7 +254,7 @@ export class ContextAnalyzer {
188
254
  continue;
189
255
  }
190
256
 
191
- // 5. Everything else
257
+ // 8. Everything else
192
258
  historyTokens += TokenCounter.countMessage(msg);
193
259
  }
194
260
 
@@ -197,7 +263,8 @@ export class ContextAnalyzer {
197
263
 
198
264
  const mk = (tokens: number): ContextSlice => ({
199
265
  tokens: Math.ceil(tokens),
200
- percent: totalTokens > 0 ? Math.round((tokens / totalTokens) * 100) : 0,
266
+ percent:
267
+ totalTokens > 0 ? Math.round((tokens / totalTokens) * 100) : 0,
201
268
  });
202
269
 
203
270
  const files_detail = Array.from(fileRegistry.values())
@@ -239,7 +306,9 @@ export class ContextAnalyzer {
239
306
  if (Array.isArray(content)) {
240
307
  for (const block of content) {
241
308
  if (block.type === "text" && typeof block.text === "string") {
242
- const match = block.text.match(/(?:\/|[A-Z]:\\)[\w./\\-]+\.\w+/);
309
+ const match = block.text.match(
310
+ /(?:\/|[A-Z]:\\)[\w./\\-]+\.\w+/,
311
+ );
243
312
  if (match) return match[0];
244
313
  }
245
314
  }
@@ -260,13 +329,20 @@ export class ContextAnalyzer {
260
329
  }
261
330
  }
262
331
 
332
+ /**
333
+ * Calculate file status based on position in message array.
334
+ * Files near the end are "active", middle are "stale", beginning are "legacy".
335
+ * This is more reliable than turn-based calculation since the context event
336
+ * replaces all messages at once.
337
+ */
263
338
  private calculateStatus(
264
- turn: number,
265
- currentTurn: number,
339
+ messageIndex: number,
340
+ totalMessages: number,
266
341
  ): FileContext["status"] {
267
- const diff = currentTurn - turn;
268
- if (diff <= 3) return "active";
269
- if (diff <= 10) return "stale";
342
+ if (totalMessages === 0) return "legacy";
343
+ const ratio = messageIndex / totalMessages;
344
+ if (ratio >= 0.7) return "active";
345
+ if (ratio >= 0.3) return "stale";
270
346
  return "legacy";
271
347
  }
272
348
 
@@ -15,8 +15,12 @@ export class ReportGenerator {
15
15
  composition: ContextComposition,
16
16
  insights: Insight[],
17
17
  contextWindow: number = 128_000,
18
+ actualTokens?: number | null,
18
19
  ): string {
19
- const total = composition.total.tokens;
20
+ // Use Pi's actual token count when available
21
+ const total = actualTokens != null && actualTokens > 0
22
+ ? actualTokens
23
+ : composition.total.tokens;
20
24
  const usagePercent =
21
25
  total > 0 ? Math.round((total / contextWindow) * 100) : 0;
22
26
 
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * pi-context-map
3
3
  * Professional Context Profiler for Pi.
4
- * v0.6.2 — Fixed Pi message format (toolCall), system prompt detection, message persistence.
4
+ * v0.7.0 — Fixed token accuracy (uses Pi's actual count), compactionSummary detection,
5
+ * auto-open browser, position-based file status, error cleanup.
5
6
  */
6
7
 
7
8
  import type {
@@ -16,6 +17,7 @@ import { LiveReportServer } from "./live-server";
16
17
  import * as path from "node:path";
17
18
  import * as fs from "node:fs";
18
19
  import * as os from "node:os";
20
+ import { exec } from "node:child_process";
19
21
 
20
22
  function makeReportPath(sessionName?: string): string {
21
23
  const dir = path.join(os.homedir(), ".pi", "context-map");
@@ -25,11 +27,24 @@ function makeReportPath(sessionName?: string): string {
25
27
  const now = new Date();
26
28
  const date = now.toISOString().split("T")[0];
27
29
  const time = now.toTimeString().split(" ")[0].replace(/:/g, "-");
28
- const safe = (sessionName || "session").replace(/[^\w.-]/g, "_").slice(0, 40);
30
+ const safe = (sessionName || "session")
31
+ .replace(/[^\w.-]/g, "_")
32
+ .slice(0, 40);
29
33
  const filename = `${date}_${time}_${safe}.html`;
30
34
  return path.join(dir, filename);
31
35
  }
32
36
 
37
+ function openBrowser(url: string): void {
38
+ const platform = process.platform;
39
+ if (platform === "win32") {
40
+ exec(`start "" "${url}"`);
41
+ } else if (platform === "darwin") {
42
+ exec(`open "${url}"`);
43
+ } else {
44
+ exec(`xdg-open "${url}"`);
45
+ }
46
+ }
47
+
33
48
  export default async function piContextMap(pi: ExtensionAPI): Promise<void> {
34
49
  const analyzer = new ContextAnalyzer();
35
50
  const liveServer = new LiveReportServer();
@@ -37,18 +52,30 @@ export default async function piContextMap(pi: ExtensionAPI): Promise<void> {
37
52
  let sessionMessages: AgentMessage[] = [];
38
53
  let currentTurn = 0;
39
54
  let contextWindow = 128_000;
55
+ let actualTokens: number | null = null;
56
+ let actualPercent: number | null = null;
40
57
  let systemPrompt = "";
41
58
  let currentReportPath = makeReportPath();
59
+ let isFirstRun = true;
42
60
 
43
- // Capture messages, context window, and system prompt from Pi system
61
+ // Capture messages, context window, system prompt, and actual token count from Pi
44
62
  pi.on("context", (event: any, ctx: any) => {
45
63
  if (event?.messages && Array.isArray(event.messages)) {
46
64
  sessionMessages = event.messages;
47
65
  }
48
66
  try {
49
67
  const usage = ctx?.getContextUsage?.();
50
- if (usage?.contextWindow && usage.contextWindow > 0) {
51
- contextWindow = usage.contextWindow;
68
+ if (usage) {
69
+ if (usage.contextWindow && usage.contextWindow > 0) {
70
+ contextWindow = usage.contextWindow;
71
+ }
72
+ // Use Pi's actual token count — this is the real value
73
+ if (usage.tokens != null && usage.tokens > 0) {
74
+ actualTokens = usage.tokens;
75
+ }
76
+ if (usage.percent != null && usage.percent > 0) {
77
+ actualPercent = usage.percent;
78
+ }
52
79
  }
53
80
  } catch {
54
81
  // Keep fallback
@@ -71,6 +98,7 @@ export default async function piContextMap(pi: ExtensionAPI): Promise<void> {
71
98
  // Update report path when session changes
72
99
  pi.on("session_start", () => {
73
100
  currentReportPath = makeReportPath();
101
+ isFirstRun = true;
74
102
  });
75
103
 
76
104
  // Persist messages on compaction so they survive reload
@@ -78,7 +106,7 @@ export default async function piContextMap(pi: ExtensionAPI): Promise<void> {
78
106
  if (event?.compactionEntry) {
79
107
  try {
80
108
  pi.appendEntry("context-map-snapshot", {
81
- messages: sessionMessages.slice(-50), // Keep last 50 messages
109
+ messages: sessionMessages.slice(-50),
82
110
  turn: currentTurn,
83
111
  timestamp: Date.now(),
84
112
  });
@@ -99,11 +127,40 @@ export default async function piContextMap(pi: ExtensionAPI): Promise<void> {
99
127
  currentTurn,
100
128
  systemPrompt,
101
129
  );
130
+
131
+ // Override with Pi's actual token count when available
132
+ if (actualTokens != null && actualTokens > 0) {
133
+ composition.actualTokens = actualTokens;
134
+ composition.actualPercent = actualPercent;
135
+ // Recalculate percentages relative to actual total
136
+ const total = actualTokens;
137
+ if (total > 0) {
138
+ composition.system.percent = Math.round(
139
+ (composition.system.tokens / total) * 100,
140
+ );
141
+ composition.tools.percent = Math.round(
142
+ (composition.tools.tokens / total) * 100,
143
+ );
144
+ composition.history.percent = Math.round(
145
+ (composition.history.tokens / total) * 100,
146
+ );
147
+ composition.files.percent = Math.round(
148
+ (composition.files.tokens / total) * 100,
149
+ );
150
+ composition.summaries.percent = Math.round(
151
+ (composition.summaries.tokens / total) * 100,
152
+ );
153
+ // Use Pi's actual total for the usage calculation
154
+ composition.total.tokens = total;
155
+ }
156
+ }
157
+
102
158
  const insights = InsightEngine.generate(composition);
103
159
  const html = ReportGenerator.generateHTML(
104
160
  composition,
105
161
  insights,
106
162
  contextWindow,
163
+ actualTokens,
107
164
  );
108
165
 
109
166
  try {
@@ -113,7 +170,7 @@ export default async function piContextMap(pi: ExtensionAPI): Promise<void> {
113
170
  }
114
171
  fs.writeFileSync(currentReportPath, html, "utf8");
115
172
  } catch (err: any) {
116
- console.error(`[pi-context-map] Failed to write report: ${err.message}`);
173
+ // Silent don't spam console
117
174
  }
118
175
 
119
176
  if (liveServer.isRunning) {
@@ -145,17 +202,27 @@ export default async function piContextMap(pi: ExtensionAPI): Promise<void> {
145
202
  criticalCount > 0
146
203
  ? `Context map generated. ${criticalCount} critical insight(s) found.`
147
204
  : "Context map generated successfully.";
148
- let details = `File: ${reportPath}`;
205
+
206
+ // Use Pi's actual percentage when available
207
+ const usageDisplay =
208
+ actualPercent != null
209
+ ? `${actualPercent.toFixed(1)}%`
210
+ : `${composition.total.percent}%`;
211
+
212
+ let details = `Usage: ${usageDisplay} of ${(contextWindow / 1000).toFixed(0)}k`;
149
213
  if (serverUrl) {
150
- details += ` | Live: ${serverUrl}`;
214
+ details += ` | ${serverUrl}`;
151
215
  }
152
- details += ` | Messages: ${sessionMessages.length}`;
153
- details += ` | System: ${composition.system.tokens}t (${composition.system.percent}%)`;
154
- details += ` | Tools: ${composition.tools.tokens}t (${composition.tools.percent}%)`;
155
216
  ctx.ui.notify(
156
217
  `${summary} ${details}`,
157
218
  criticalCount > 0 ? "warning" : "success",
158
219
  );
220
+
221
+ // Auto-open browser on first run
222
+ if (isFirstRun && serverUrl) {
223
+ openBrowser(serverUrl);
224
+ isFirstRun = false;
225
+ }
159
226
  } catch (error: any) {
160
227
  ctx.ui.notify(
161
228
  `Failed to generate context map: ${error.message}`,
@@ -183,15 +250,18 @@ export default async function piContextMap(pi: ExtensionAPI): Promise<void> {
183
250
  try {
184
251
  const { composition, insights, reportPath } = await runAnalysis();
185
252
  const usagePercent =
186
- composition.total.tokens > 0
187
- ? Math.round((composition.total.tokens / contextWindow) * 100)
188
- : 0;
253
+ actualPercent != null
254
+ ? actualPercent
255
+ : composition.total.tokens > 0
256
+ ? Math.round(
257
+ (composition.total.tokens / contextWindow) * 100,
258
+ )
259
+ : 0;
189
260
  const summary =
190
- `Context: ${composition.total.tokens.toLocaleString()} tokens total. ` +
261
+ `Context: ${composition.total.tokens.toLocaleString()} tokens (${usagePercent.toFixed(1)}% of ${(contextWindow / 1000).toFixed(0)}k). ` +
191
262
  `System ${composition.system.percent}%, Tools ${composition.tools.percent}%, ` +
192
263
  `History ${composition.history.percent}%, Files ${composition.files.percent}%, ` +
193
264
  `Summaries ${composition.summaries.percent}%. ` +
194
- `Usage: ${usagePercent}% of ${(contextWindow / 1000).toFixed(0)}k window. ` +
195
265
  `Messages: ${sessionMessages.length}. ` +
196
266
  `${insights.length} insight(s).`;
197
267
  return {
@@ -200,7 +270,8 @@ export default async function piContextMap(pi: ExtensionAPI): Promise<void> {
200
270
  summary,
201
271
  "",
202
272
  ...insights.map(
203
- (i) => `[${i.severity.toUpperCase()}] ${i.title}: ${i.message}`,
273
+ (i) =>
274
+ `[${i.severity.toUpperCase()}] ${i.title}: ${i.message}`,
204
275
  ),
205
276
  `Report: ${reportPath}`,
206
277
  serverUrl ? `Live: ${serverUrl}` : "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-context-map",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "Professional context profiler for Pi that visualizes the session context window, token distribution, and integrates with Nexus packages for actionable insights.",
5
5
  "keywords": [
6
6
  "pi-package",