pi-context-map 0.6.0 → 0.6.2

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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.2] - 2026-06-15
4
+ ### Bug Fixes
5
+ - **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.
6
+ - **System prompt detection**: Now accepts `systemPrompt` parameter from Pi's `ctx.getSystemPrompt()`. System slice no longer shows 0%.
7
+ - **Tool results detection**: Changed to `role === "toolResult"` to match Pi's actual message format.
8
+ - **File tracking from tool results**: Now extracts file paths from `toolResult` messages (read/write/edit tool results).
9
+
10
+ ### Features
11
+ - **Message persistence**: Messages are saved via `appendEntry` on compaction to survive session reloads.
12
+ - **Enhanced diagnostics**: `/context-map` command now shows message count, system tokens, and tool tokens in the notification.
13
+
14
+ ## [0.6.1] - 2026-06-15
15
+ ### Bug Fixes
16
+ - **Fixed libuv assertion on Windows**: Removed `process.on('exit')` handler and `process.exit(0)` calls that left server handles open. Server now closes synchronously via `closeAllConnections()`.
17
+ - **Synchronous stop()**: `isRunning` returns `false` immediately after `stop()` instead of after async callback.
18
+
3
19
  ## [0.6.0] - 2026-06-15
4
20
  ### Bug Fixes
5
21
  - **Fixed composition analysis**: Changed `role === "tool"` to `role === "toolResult"` to match Pi's actual message format. Tools and files now show correct percentages instead of 0%.
@@ -2,6 +2,14 @@
2
2
  * ContextAnalyzer
3
3
  * Parses Pi session messages to identify the active working set of files,
4
4
  * their token weights, and their temporal status.
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)[] }
10
+ *
11
+ * ToolCall: { type: "toolCall", id, name, arguments }
12
+ * ToolCall.id maps to ToolResultMessage.toolCallId
5
13
  */
6
14
  import { TokenCounter } from "./token-counter";
7
15
 
@@ -37,6 +45,7 @@ export class ContextAnalyzer {
37
45
  public analyzeByType(
38
46
  messages: any[],
39
47
  currentTurn: number,
48
+ systemPrompt?: string,
40
49
  ): ContextComposition {
41
50
  const fileRegistry = new Map<string, FileContext>();
42
51
 
@@ -46,16 +55,20 @@ export class ContextAnalyzer {
46
55
  let fileTokens = 0;
47
56
  let summaryTokens = 0;
48
57
 
58
+ // Count system prompt tokens if provided
59
+ if (systemPrompt && systemPrompt.length > 0) {
60
+ systemTokens += TokenCounter.count(systemPrompt);
61
+ }
62
+
49
63
  for (let index = 0; index < messages.length; index++) {
50
64
  const msg = messages[index];
51
65
  const turn = index + 1;
52
66
  const role = msg.role || "";
53
- const msgType = msg.type || "";
54
67
 
55
- // 1. Compaction summaries
68
+ // 1. Compaction summaries (Pi compaction entries)
56
69
  if (
57
70
  role === "compaction" ||
58
- msgType === "compaction" ||
71
+ msg.type === "compaction" ||
59
72
  msg.customType === "compaction" ||
60
73
  msg.compactionEntry
61
74
  ) {
@@ -63,26 +76,46 @@ export class ContextAnalyzer {
63
76
  continue;
64
77
  }
65
78
 
66
- // 2. System messages
67
- if (role === "system" || msgType === "system") {
68
- systemTokens += TokenCounter.countMessage(msg);
69
- continue;
70
- }
71
-
72
- // 3. Tool results (Pi uses "toolResult")
73
- if (role === "toolResult" || role === "tool") {
79
+ // 2. Tool results (Pi uses role="toolResult")
80
+ if (role === "toolResult") {
74
81
  toolTokens += TokenCounter.countMessage(msg);
82
+ // Track file content from tool results
83
+ const toolName = msg.toolName || "";
84
+ if (
85
+ toolName === "read" ||
86
+ toolName === "write" ||
87
+ toolName === "edit"
88
+ ) {
89
+ const content = msg.content;
90
+ const path = this.extractPathFromToolResult(content);
91
+ if (path) {
92
+ const w = TokenCounter.countMessage(msg);
93
+ fileTokens += w;
94
+ if (!fileRegistry.has(path)) {
95
+ fileRegistry.set(path, {
96
+ path,
97
+ weight: w,
98
+ lastOp: {
99
+ type: this.getOpType(toolName),
100
+ turn,
101
+ timestamp: msg.timestamp || Date.now(),
102
+ },
103
+ status: this.calculateStatus(turn, currentTurn),
104
+ });
105
+ }
106
+ }
107
+ }
75
108
  continue;
76
109
  }
77
110
 
78
- // 4. User messages — track file attachments
111
+ // 3. User messages
79
112
  if (role === "user") {
80
113
  historyTokens += TokenCounter.countMessage(msg);
114
+ // Track file attachments (images, file paths in text)
81
115
  if (Array.isArray(msg.content)) {
82
116
  for (const block of msg.content) {
83
- if (block.type === "image" || block.type === "image_url") {
84
- const p =
85
- block.source?.url || block.image_url?.url || "[image]";
117
+ if (block.type === "image") {
118
+ const p = "[image]";
86
119
  const w = TokenCounter.count(JSON.stringify(block));
87
120
  fileTokens += w;
88
121
  if (!fileRegistry.has(p)) {
@@ -124,23 +157,19 @@ export class ContextAnalyzer {
124
157
  continue;
125
158
  }
126
159
 
127
- // 5. Assistant messages — track tool_use blocks
160
+ // 4. Assistant messages — track toolCall blocks
128
161
  if (role === "assistant") {
129
162
  historyTokens += TokenCounter.countMessage(msg);
130
163
  if (Array.isArray(msg.content)) {
131
164
  for (const block of msg.content) {
132
- if (block.type === "tool_use") {
133
- const input = block.input as Record<string, any>;
134
- const p = this.extractPath(block.name, input);
165
+ // Pi uses type="toolCall" with id, name, arguments
166
+ if (block.type === "toolCall") {
167
+ const p = this.extractPath(block.name, block.arguments);
135
168
  if (p) {
136
169
  const opType = this.getOpType(block.name);
137
- const result = this.findToolResult(
138
- messages,
139
- index,
140
- block.id,
141
- );
170
+ const result = this.findToolResult(messages, index, block.id);
142
171
  const content = result?.content || "";
143
- const w = TokenCounter.count(String(content));
172
+ const w = TokenCounter.count(String(JSON.stringify(content)));
144
173
  fileTokens += w;
145
174
  fileRegistry.set(p, {
146
175
  path: p,
@@ -159,7 +188,7 @@ export class ContextAnalyzer {
159
188
  continue;
160
189
  }
161
190
 
162
- // 6. Everything else
191
+ // 5. Everything else
163
192
  historyTokens += TokenCounter.countMessage(msg);
164
193
  }
165
194
 
@@ -168,8 +197,7 @@ export class ContextAnalyzer {
168
197
 
169
198
  const mk = (tokens: number): ContextSlice => ({
170
199
  tokens: Math.ceil(tokens),
171
- percent:
172
- totalTokens > 0 ? Math.round((tokens / totalTokens) * 100) : 0,
200
+ percent: totalTokens > 0 ? Math.round((tokens / totalTokens) * 100) : 0,
173
201
  });
174
202
 
175
203
  const files_detail = Array.from(fileRegistry.values())
@@ -192,12 +220,13 @@ export class ContextAnalyzer {
192
220
  return this.analyzeByType(messages, currentTurn);
193
221
  }
194
222
 
195
- private extractPath(toolName: string, input: any): string | null {
223
+ private extractPath(toolName: string, args: any): string | null {
224
+ if (!args || typeof args !== "object") return null;
196
225
  if (toolName === "read" || toolName === "write" || toolName === "edit") {
197
- return typeof input.path === "string" ? input.path : null;
226
+ return typeof args.path === "string" ? args.path : null;
198
227
  }
199
228
  if (toolName === "bash") {
200
- const match = input.command?.match(
229
+ const match = args.command?.match(
201
230
  /(?:cat|ls|rm|mv|cp|vi|nano)\s+([^\s;]+)/,
202
231
  );
203
232
  return match ? match[1] : null;
@@ -205,6 +234,19 @@ export class ContextAnalyzer {
205
234
  return null;
206
235
  }
207
236
 
237
+ private extractPathFromToolResult(content: any): string | null {
238
+ if (typeof content === "string") return null;
239
+ if (Array.isArray(content)) {
240
+ for (const block of content) {
241
+ if (block.type === "text" && typeof block.text === "string") {
242
+ const match = block.text.match(/(?:\/|[A-Z]:\\)[\w./\\-]+\.\w+/);
243
+ if (match) return match[0];
244
+ }
245
+ }
246
+ }
247
+ return null;
248
+ }
249
+
208
250
  private getOpType(toolName: string): FileOp["type"] {
209
251
  switch (toolName) {
210
252
  case "write":
@@ -234,13 +276,12 @@ export class ContextAnalyzer {
234
276
  toolId: string,
235
277
  ): any {
236
278
  for (let i = toolTurnIndex + 1; i < messages.length; i++) {
237
- if (
238
- messages[i].role === "toolResult" &&
239
- messages[i].tool_call_id === toolId
240
- ) {
241
- return messages[i];
279
+ const m = messages[i];
280
+ // Pi uses role="toolResult" and toolCallId (not tool_call_id)
281
+ if (m.role === "toolResult" && m.toolCallId === toolId) {
282
+ return m;
242
283
  }
243
- if (messages[i].role === "assistant") break;
284
+ if (m.role === "assistant") break;
244
285
  }
245
286
  return null;
246
287
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * pi-context-map
3
3
  * Professional Context Profiler for Pi.
4
- * v0.5.1Dynamic context window, dark mode, session-unique reports.
4
+ * v0.6.2Fixed Pi message format (toolCall), system prompt detection, message persistence.
5
5
  */
6
6
 
7
7
  import type {
@@ -25,9 +25,7 @@ function makeReportPath(sessionName?: string): string {
25
25
  const now = new Date();
26
26
  const date = now.toISOString().split("T")[0];
27
27
  const time = now.toTimeString().split(" ")[0].replace(/:/g, "-");
28
- const safe = (sessionName || "session")
29
- .replace(/[^\w.-]/g, "_")
30
- .slice(0, 40);
28
+ const safe = (sessionName || "session").replace(/[^\w.-]/g, "_").slice(0, 40);
31
29
  const filename = `${date}_${time}_${safe}.html`;
32
30
  return path.join(dir, filename);
33
31
  }
@@ -39,9 +37,10 @@ export default async function piContextMap(pi: ExtensionAPI): Promise<void> {
39
37
  let sessionMessages: AgentMessage[] = [];
40
38
  let currentTurn = 0;
41
39
  let contextWindow = 128_000;
40
+ let systemPrompt = "";
42
41
  let currentReportPath = makeReportPath();
43
42
 
44
- // Capture messages and context window from Pi system
43
+ // Capture messages, context window, and system prompt from Pi system
45
44
  pi.on("context", (event: any, ctx: any) => {
46
45
  if (event?.messages && Array.isArray(event.messages)) {
47
46
  sessionMessages = event.messages;
@@ -54,6 +53,15 @@ export default async function piContextMap(pi: ExtensionAPI): Promise<void> {
54
53
  } catch {
55
54
  // Keep fallback
56
55
  }
56
+ // Get system prompt from Pi
57
+ try {
58
+ const sp = ctx?.getSystemPrompt?.();
59
+ if (sp && typeof sp === "string") {
60
+ systemPrompt = sp;
61
+ }
62
+ } catch {
63
+ // Keep empty
64
+ }
57
65
  });
58
66
 
59
67
  pi.on("turn_start", () => {
@@ -65,13 +73,32 @@ export default async function piContextMap(pi: ExtensionAPI): Promise<void> {
65
73
  currentReportPath = makeReportPath();
66
74
  });
67
75
 
76
+ // Persist messages on compaction so they survive reload
77
+ pi.on("session_compact", (event: any) => {
78
+ if (event?.compactionEntry) {
79
+ try {
80
+ pi.appendEntry("context-map-snapshot", {
81
+ messages: sessionMessages.slice(-50), // Keep last 50 messages
82
+ turn: currentTurn,
83
+ timestamp: Date.now(),
84
+ });
85
+ } catch {
86
+ // Ignore persistence errors
87
+ }
88
+ }
89
+ });
90
+
68
91
  async function runAnalysis(): Promise<{
69
92
  composition: ReturnType<typeof analyzer.analyzeByType>;
70
93
  insights: ReturnType<typeof InsightEngine.generate>;
71
94
  reportPath: string;
72
95
  }> {
73
96
  const messages = sessionMessages.length > 0 ? sessionMessages : [];
74
- const composition = analyzer.analyzeByType(messages, currentTurn);
97
+ const composition = analyzer.analyzeByType(
98
+ messages,
99
+ currentTurn,
100
+ systemPrompt,
101
+ );
75
102
  const insights = InsightEngine.generate(composition);
76
103
  const html = ReportGenerator.generateHTML(
77
104
  composition,
@@ -110,7 +137,7 @@ export default async function piContextMap(pi: ExtensionAPI): Promise<void> {
110
137
 
111
138
  ctx.ui.notify("Analyzing session context...", "info");
112
139
  try {
113
- const { insights, reportPath } = await runAnalysis();
140
+ const { composition, insights, reportPath } = await runAnalysis();
114
141
  const criticalCount = insights.filter(
115
142
  (i) => i.severity === "critical",
116
143
  ).length;
@@ -122,6 +149,9 @@ export default async function piContextMap(pi: ExtensionAPI): Promise<void> {
122
149
  if (serverUrl) {
123
150
  details += ` | Live: ${serverUrl}`;
124
151
  }
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}%)`;
125
155
  ctx.ui.notify(
126
156
  `${summary} ${details}`,
127
157
  criticalCount > 0 ? "warning" : "success",
@@ -139,7 +169,7 @@ export default async function piContextMap(pi: ExtensionAPI): Promise<void> {
139
169
  name: "context-map",
140
170
  label: "Context Map",
141
171
  description:
142
- "Analyze the current session context composition and return actionable insights. The live localhost report will auto-update.",
172
+ "Analyze the current session context composition and return actionable insights.",
143
173
  parameters: {
144
174
  type: "object",
145
175
  properties: {},
@@ -162,7 +192,8 @@ export default async function piContextMap(pi: ExtensionAPI): Promise<void> {
162
192
  `History ${composition.history.percent}%, Files ${composition.files.percent}%, ` +
163
193
  `Summaries ${composition.summaries.percent}%. ` +
164
194
  `Usage: ${usagePercent}% of ${(contextWindow / 1000).toFixed(0)}k window. ` +
165
- `${insights.length} insight(s) generated.`;
195
+ `Messages: ${sessionMessages.length}. ` +
196
+ `${insights.length} insight(s).`;
166
197
  return {
167
198
  type: "text" as const,
168
199
  content: [
@@ -211,13 +242,10 @@ export default async function piContextMap(pi: ExtensionAPI): Promise<void> {
211
242
  liveServer.stop();
212
243
  });
213
244
 
214
- process.on("exit", () => liveServer.stop());
215
245
  process.on("SIGINT", () => {
216
246
  liveServer.stop();
217
- process.exit(0);
218
247
  });
219
248
  process.on("SIGTERM", () => {
220
249
  liveServer.stop();
221
- process.exit(0);
222
250
  });
223
251
  }
@@ -94,18 +94,19 @@ export class LiveReportServer {
94
94
  for (const client of this.clients) {
95
95
  try {
96
96
  client.end();
97
- } catch (err) {
98
- // Ignore errors on close
97
+ } catch {
98
+ // Ignore
99
99
  }
100
100
  }
101
101
  this.clients.clear();
102
102
 
103
- // Close the server
104
- this.server.close((err) => {
105
- if (err) {
106
- console.error(`[pi-context-map] Error closing server: ${err.message}`);
107
- }
108
- });
103
+ // Force-close all connections synchronously (Node 18.2+)
104
+ if (typeof this.server.closeAllConnections === "function") {
105
+ this.server.closeAllConnections();
106
+ }
107
+
108
+ // Close server and reset state synchronously
109
+ this.server.close();
109
110
  this.server = null;
110
111
  this.port = 0;
111
112
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-context-map",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
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",