pi-context-map 0.5.0 → 0.6.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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.0] - 2026-06-15
4
+ ### Bug Fixes
5
+ - **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%.
6
+ - **File attachment detection**: Now detects images (type: "image") and file paths in user messages, not just assistant tool_use blocks.
7
+ - **Compaction summary detection**: Improved detection of Pi compaction entries via `customType` field.
8
+
9
+ ### Features
10
+ - **Session-unique reports**: Each report is saved with date, time, and session name (e.g., `2026-06-15_14-30-00_my-session.html`). Old reports are preserved for history.
11
+ - **Auto-report path on session start**: New session gets a fresh report path automatically.
12
+
13
+ ## [0.5.1] - 2026-06-15
14
+ ### Bug Fixes
15
+ - **Fixed toggle symbol**: Dark mode now correctly shows sun icon + "Light" label. Light mode shows moon icon + "Dark" label. Uses fresh DOM queries to survive SSE body replacement.
16
+ - **Toggle moved to corner**: Button is now fixed in the top-right corner of the page, not inline with the live badge.
17
+ - **Event delegation**: Theme toggle click handler uses event delegation so it survives SSE body replacement without re-binding.
18
+
3
19
  ## [0.5.0] - 2026-06-15
4
20
  ### Features
5
21
  - **Dark mode toggle**: Light/dark theme switcher in the report header. Preference saved to localStorage and restored on next load. Theme persists across SSE live updates.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * ContextAnalyzer
3
- * Responsible for parsing Pi session messages to identify the active working set of files,
4
- * their token weights, and their temporal status. Uses the code-aware TokenCounter.
3
+ * Parses Pi session messages to identify the active working set of files,
4
+ * their token weights, and their temporal status.
5
5
  */
6
6
  import { TokenCounter } from "./token-counter";
7
7
 
@@ -13,7 +13,7 @@ export interface FileOp {
13
13
 
14
14
  export interface FileContext {
15
15
  path: string;
16
- weight: number; // Estimated tokens
16
+ weight: number;
17
17
  lastOp: FileOp;
18
18
  status: "active" | "stale" | "legacy";
19
19
  }
@@ -34,11 +34,6 @@ export interface ContextComposition {
34
34
  }
35
35
 
36
36
  export class ContextAnalyzer {
37
- /**
38
- * Analyze session messages to produce a structured ContextComposition.
39
- * @param messages The full session conversation history.
40
- * @param currentTurn The current turn number.
41
- */
42
37
  public analyzeByType(
43
38
  messages: any[],
44
39
  currentTurn: number,
@@ -51,73 +46,130 @@ export class ContextAnalyzer {
51
46
  let fileTokens = 0;
52
47
  let summaryTokens = 0;
53
48
 
54
- messages.forEach((msg, index) => {
49
+ for (let index = 0; index < messages.length; index++) {
50
+ const msg = messages[index];
55
51
  const turn = index + 1;
52
+ const role = msg.role || "";
53
+ const msgType = msg.type || "";
56
54
 
57
- // 1. Categorize and count
58
- if (msg.role === "system") {
59
- systemTokens += TokenCounter.countMessage(msg);
60
- return;
61
- }
62
-
63
- if (msg.role === "tool") {
64
- toolTokens += TokenCounter.countMessage(msg);
65
- return;
66
- }
67
-
68
- // Detect compaction summaries (Pi uses customType or specific role)
55
+ // 1. Compaction summaries
69
56
  if (
70
- msg.role === "compaction" ||
71
- msg.type === "compaction" ||
57
+ role === "compaction" ||
58
+ msgType === "compaction" ||
59
+ msg.customType === "compaction" ||
72
60
  msg.compactionEntry
73
61
  ) {
74
62
  summaryTokens += TokenCounter.countMessage(msg);
75
- return;
63
+ continue;
76
64
  }
77
65
 
78
- if (msg.role === "user" || msg.role === "assistant") {
79
- historyTokens += TokenCounter.countMessage(msg);
80
- } else {
81
- // Default to history
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") {
74
+ toolTokens += TokenCounter.countMessage(msg);
75
+ continue;
76
+ }
77
+
78
+ // 4. User messages — track file attachments
79
+ if (role === "user") {
82
80
  historyTokens += TokenCounter.countMessage(msg);
81
+ if (Array.isArray(msg.content)) {
82
+ 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]";
86
+ const w = TokenCounter.count(JSON.stringify(block));
87
+ fileTokens += w;
88
+ if (!fileRegistry.has(p)) {
89
+ fileRegistry.set(p, {
90
+ path: p,
91
+ weight: w,
92
+ lastOp: {
93
+ type: "read",
94
+ turn,
95
+ timestamp: msg.timestamp || Date.now(),
96
+ },
97
+ status: this.calculateStatus(turn, currentTurn),
98
+ });
99
+ }
100
+ }
101
+ if (block.type === "text" && typeof block.text === "string") {
102
+ const matches = block.text.match(
103
+ /(?:\/|[A-Z]:\\)[\w./\\-]+\.\w+/g,
104
+ );
105
+ if (matches) {
106
+ for (const m of matches) {
107
+ if (!fileRegistry.has(m)) {
108
+ fileRegistry.set(m, {
109
+ path: m,
110
+ weight: TokenCounter.count(m),
111
+ lastOp: {
112
+ type: "read",
113
+ turn,
114
+ timestamp: msg.timestamp || Date.now(),
115
+ },
116
+ status: this.calculateStatus(turn, currentTurn),
117
+ });
118
+ }
119
+ }
120
+ }
121
+ }
122
+ }
123
+ }
124
+ continue;
83
125
  }
84
126
 
85
- // 2. File tracking (only on assistant tool_use blocks)
86
- if (msg.role === "assistant" && Array.isArray(msg.content)) {
87
- for (const block of msg.content) {
88
- if (block.type === "tool_use") {
89
- const input = block.input as Record<string, any>;
90
- const path = this.extractPath(block.name, input);
91
-
92
- if (path) {
93
- const opType = this.getOpType(block.name);
94
- const result = this.findToolResult(messages, index, block.id);
95
- const content = result?.content || "";
96
- const weight = TokenCounter.count(String(content));
97
- fileTokens += weight;
98
-
99
- fileRegistry.set(path, {
100
- path,
101
- weight,
102
- lastOp: {
103
- type: opType,
104
- turn,
105
- timestamp: msg.timestamp || Date.now(),
106
- },
107
- status: this.calculateStatus(turn, currentTurn),
108
- });
127
+ // 5. Assistant messages track tool_use blocks
128
+ if (role === "assistant") {
129
+ historyTokens += TokenCounter.countMessage(msg);
130
+ if (Array.isArray(msg.content)) {
131
+ 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);
135
+ if (p) {
136
+ const opType = this.getOpType(block.name);
137
+ const result = this.findToolResult(
138
+ messages,
139
+ index,
140
+ block.id,
141
+ );
142
+ const content = result?.content || "";
143
+ const w = TokenCounter.count(String(content));
144
+ fileTokens += w;
145
+ fileRegistry.set(p, {
146
+ path: p,
147
+ weight: w,
148
+ lastOp: {
149
+ type: opType,
150
+ turn,
151
+ timestamp: msg.timestamp || Date.now(),
152
+ },
153
+ status: this.calculateStatus(turn, currentTurn),
154
+ });
155
+ }
109
156
  }
110
157
  }
111
158
  }
159
+ continue;
112
160
  }
113
- });
161
+
162
+ // 6. Everything else
163
+ historyTokens += TokenCounter.countMessage(msg);
164
+ }
114
165
 
115
166
  const totalTokens =
116
167
  systemTokens + toolTokens + historyTokens + fileTokens + summaryTokens;
117
168
 
118
169
  const mk = (tokens: number): ContextSlice => ({
119
170
  tokens: Math.ceil(tokens),
120
- percent: totalTokens > 0 ? Math.round((tokens / totalTokens) * 100) : 0,
171
+ percent:
172
+ totalTokens > 0 ? Math.round((tokens / totalTokens) * 100) : 0,
121
173
  });
122
174
 
123
175
  const files_detail = Array.from(fileRegistry.values())
@@ -182,7 +234,10 @@ export class ContextAnalyzer {
182
234
  toolId: string,
183
235
  ): any {
184
236
  for (let i = toolTurnIndex + 1; i < messages.length; i++) {
185
- if (messages[i].role === "tool" && messages[i].tool_call_id === toolId) {
237
+ if (
238
+ messages[i].role === "toolResult" &&
239
+ messages[i].tool_call_id === toolId
240
+ ) {
186
241
  return messages[i];
187
242
  }
188
243
  if (messages[i].role === "assistant") break;
@@ -17,7 +17,8 @@ export class ReportGenerator {
17
17
  contextWindow: number = 128_000,
18
18
  ): string {
19
19
  const total = composition.total.tokens;
20
- const usagePercent = total > 0 ? Math.round((total / contextWindow) * 100) : 0;
20
+ const usagePercent =
21
+ total > 0 ? Math.round((total / contextWindow) * 100) : 0;
21
22
 
22
23
  const fileCards = composition.files_detail
23
24
  .map(
@@ -503,14 +504,12 @@ h2:first-of-type { margin-top: 48px; }
503
504
  <div class="container">
504
505
 
505
506
  <header>
506
- <div style="display:flex;align-items:center;gap:12px;margin-bottom:20px;flex-wrap:wrap;">
507
- <div class="live-badge"><span class="dot"></span>Live</div>
508
- <button id="themeToggle" style="display:inline-flex;align-items:center;gap:6px;padding:6px 14px;border:1px solid var(--hairline);border-radius:20px;background:var(--surface);color:var(--ink-secondary);font:inherit;font-size:12px;font-weight:500;cursor:pointer;transition:all 0.2s;white-space:nowrap;" aria-label="Toggle theme">
509
- <svg id="themeIconSun" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none;"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
510
- <svg id="themeIconMoon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
511
- <span id="themeLabel">Dark</span>
512
- </button>
513
- </div>
507
+ <button id="themeToggle" style="position:fixed;top:24px;right:24px;z-index:100;display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border:1px solid var(--hairline);border-radius:20px;background:var(--surface);color:var(--ink-secondary);font:inherit;font-size:12px;font-weight:500;cursor:pointer;transition:all 0.2s;box-shadow:0 2px 8px rgba(0,0,0,0.08);" aria-label="Toggle theme">
508
+ <svg id="themeIconSun" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none;"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
509
+ <svg id="themeIconMoon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
510
+ <span id="themeLabel">Dark</span>
511
+ </button>
512
+ <div class="live-badge"><span class="dot"></span>Live</div>
514
513
  <h1>Context Profiler</h1>
515
514
  <p class="subtitle">Session context window breakdown with actionable recommendations</p>
516
515
 
@@ -623,20 +622,23 @@ h2:first-of-type { margin-top: 48px; }
623
622
  update();
624
623
 
625
624
  // Theme toggle
626
- var toggle = document.getElementById('themeToggle');
627
- var label = document.getElementById('themeLabel');
628
- var sunIcon = document.getElementById('themeIconSun');
629
- var moonIcon = document.getElementById('themeIconMoon');
630
625
  function applyTheme(t) {
631
626
  document.documentElement.setAttribute('data-theme', t);
632
627
  localStorage.setItem('context-map-theme', t);
633
- if (label) label.textContent = t === 'dark' ? 'Light' : 'Dark';
634
- if (sunIcon) sunIcon.style.display = t === 'dark' ? '' : 'none';
635
- if (moonIcon) moonIcon.style.display = t === 'dark' ? 'none' : '';
628
+ // Query fresh each time (DOM may have been replaced by SSE)
629
+ var lbl = document.getElementById('themeLabel');
630
+ var sun = document.getElementById('themeIconSun');
631
+ var moon = document.getElementById('themeIconMoon');
632
+ if (lbl) lbl.textContent = t === 'dark' ? 'Light' : 'Dark';
633
+ if (sun) sun.style.display = t === 'dark' ? '' : 'none';
634
+ if (moon) moon.style.display = t === 'dark' ? 'none' : '';
636
635
  }
637
636
  var saved = localStorage.getItem('context-map-theme');
638
637
  applyTheme(saved || 'light');
639
- if (toggle) toggle.addEventListener('click', function() {
638
+ // Use event delegation on document so clicks survive body replacement
639
+ document.addEventListener('click', function(e) {
640
+ var btn = e.target.closest('#themeToggle');
641
+ if (!btn) return;
640
642
  var cur = document.documentElement.getAttribute('data-theme');
641
643
  applyTheme(cur === 'dark' ? 'light' : 'dark');
642
644
  });
@@ -673,15 +675,7 @@ h2:first-of-type { margin-top: 48px; }
673
675
  }
674
676
  // Restore theme after body replacement
675
677
  applyTheme(currentTheme);
676
- // Re-bind new theme toggle button
677
- var newToggle = document.getElementById('themeToggle');
678
- if (newToggle) {
679
- newToggle.addEventListener('click', function() {
680
- var cur = document.documentElement.getAttribute('data-theme');
681
- applyTheme(cur === 'dark' ? 'light' : 'dark');
682
- });
683
- }
684
- // Re-bind new file search/filter
678
+ // Re-bind file search/filter (direct listeners, not delegated)
685
679
  var ns = document.getElementById('fileSearch');
686
680
  var nf = document.getElementById('fileFilter');
687
681
  if (ns) ns.addEventListener('input', update);
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * pi-context-map
3
3
  * Professional Context Profiler for Pi.
4
- * v0.4.1 — Fixes session.messages crash, tool registration signature, adds tests.
4
+ * v0.5.1 — Dynamic context window, dark mode, session-unique reports.
5
5
  */
6
6
 
7
7
  import type {
@@ -14,46 +14,57 @@ import { ReportGenerator } from "./generator";
14
14
  import { InsightEngine } from "./insights";
15
15
  import { LiveReportServer } from "./live-server";
16
16
  import * as path from "node:path";
17
+ import * as fs from "node:fs";
17
18
  import * as os from "node:os";
18
19
 
19
- const REPORT_PATH = path.join(
20
- os.homedir(),
21
- ".pi",
22
- "context-map",
23
- "report.html",
24
- );
20
+ function makeReportPath(sessionName?: string): string {
21
+ const dir = path.join(os.homedir(), ".pi", "context-map");
22
+ if (!fs.existsSync(dir)) {
23
+ fs.mkdirSync(dir, { recursive: true });
24
+ }
25
+ const now = new Date();
26
+ const date = now.toISOString().split("T")[0];
27
+ const time = now.toTimeString().split(" ")[0].replace(/:/g, "-");
28
+ const safe = (sessionName || "session")
29
+ .replace(/[^\w.-]/g, "_")
30
+ .slice(0, 40);
31
+ const filename = `${date}_${time}_${safe}.html`;
32
+ return path.join(dir, filename);
33
+ }
25
34
 
26
35
  export default async function piContextMap(pi: ExtensionAPI): Promise<void> {
27
36
  const analyzer = new ContextAnalyzer();
28
37
  const liveServer = new LiveReportServer();
29
38
 
30
- // Accumulate messages from events — this is the correct way to access
31
- // session messages in Pi. (pi as any).session?.messages does NOT exist.
32
39
  let sessionMessages: AgentMessage[] = [];
33
40
  let currentTurn = 0;
34
- let contextWindow = 128_000; // fallback, updated from Pi system
41
+ let contextWindow = 128_000;
42
+ let currentReportPath = makeReportPath();
35
43
 
36
- // Capture messages and context info before each LLM call
44
+ // Capture messages and context window from Pi system
37
45
  pi.on("context", (event: any, ctx: any) => {
38
46
  if (event?.messages && Array.isArray(event.messages)) {
39
47
  sessionMessages = event.messages;
40
48
  }
41
- // Fetch actual context window from Pi system
42
49
  try {
43
50
  const usage = ctx?.getContextUsage?.();
44
51
  if (usage?.contextWindow && usage.contextWindow > 0) {
45
52
  contextWindow = usage.contextWindow;
46
53
  }
47
54
  } catch {
48
- // Ignore — keep fallback
55
+ // Keep fallback
49
56
  }
50
57
  });
51
58
 
52
- // Track turns
53
59
  pi.on("turn_start", () => {
54
60
  currentTurn++;
55
61
  });
56
62
 
63
+ // Update report path when session changes
64
+ pi.on("session_start", () => {
65
+ currentReportPath = makeReportPath();
66
+ });
67
+
57
68
  async function runAnalysis(): Promise<{
58
69
  composition: ReturnType<typeof analyzer.analyzeByType>;
59
70
  insights: ReturnType<typeof InsightEngine.generate>;
@@ -68,30 +79,25 @@ export default async function piContextMap(pi: ExtensionAPI): Promise<void> {
68
79
  contextWindow,
69
80
  );
70
81
 
71
- // Write to disk
72
82
  try {
73
- const fs = await import("node:fs");
74
- const dir = path.dirname(REPORT_PATH);
83
+ const dir = path.dirname(currentReportPath);
75
84
  if (!fs.existsSync(dir)) {
76
85
  fs.mkdirSync(dir, { recursive: true });
77
86
  }
78
- fs.writeFileSync(REPORT_PATH, html, "utf8");
87
+ fs.writeFileSync(currentReportPath, html, "utf8");
79
88
  } catch (err: any) {
80
89
  console.error(`[pi-context-map] Failed to write report: ${err.message}`);
81
90
  }
82
91
 
83
- // Push to live server if running
84
92
  if (liveServer.isRunning) {
85
- liveServer.update(html, REPORT_PATH);
93
+ liveServer.update(html, currentReportPath);
86
94
  }
87
95
 
88
- return { composition, insights, reportPath: REPORT_PATH };
96
+ return { composition, insights, reportPath: currentReportPath };
89
97
  }
90
98
 
91
- // Start live server
92
99
  const serverUrl = await liveServer.start();
93
100
 
94
- // Register /context-map command
95
101
  pi.registerCommand("context-map", {
96
102
  description:
97
103
  "Generate a visual context map with actionable insights. Use 'stop' to terminate the live server.",
@@ -129,7 +135,6 @@ export default async function piContextMap(pi: ExtensionAPI): Promise<void> {
129
135
  },
130
136
  });
131
137
 
132
- // Register the tool for agent use
133
138
  pi.registerTool({
134
139
  name: "context-map",
135
140
  label: "Context Map",
@@ -146,7 +151,7 @@ export default async function piContextMap(pi: ExtensionAPI): Promise<void> {
146
151
  _ctx: any,
147
152
  ) {
148
153
  try {
149
- const { composition, insights } = await runAnalysis();
154
+ const { composition, insights, reportPath } = await runAnalysis();
150
155
  const usagePercent =
151
156
  composition.total.tokens > 0
152
157
  ? Math.round((composition.total.tokens / contextWindow) * 100)
@@ -166,7 +171,8 @@ export default async function piContextMap(pi: ExtensionAPI): Promise<void> {
166
171
  ...insights.map(
167
172
  (i) => `[${i.severity.toUpperCase()}] ${i.title}: ${i.message}`,
168
173
  ),
169
- serverUrl ? `Live report: ${serverUrl}` : "",
174
+ `Report: ${reportPath}`,
175
+ serverUrl ? `Live: ${serverUrl}` : "",
170
176
  ]
171
177
  .filter(Boolean)
172
178
  .join("\n"),
@@ -181,34 +187,30 @@ export default async function piContextMap(pi: ExtensionAPI): Promise<void> {
181
187
  },
182
188
  });
183
189
 
184
- // Auto-warning on high context before compaction
185
190
  pi.on("session_before_compact", (_event: any, ctx: any) => {
186
191
  const tokens = _event?.preparation?.tokensBefore;
187
192
  if (tokens && tokens > 100_000) {
188
193
  ctx.ui.notify(
189
- `High context load detected (${(tokens / 1000).toFixed(1)}k tokens). Try /context-map to see what's consuming space.`,
194
+ `High context load (${(tokens / 1000).toFixed(1)}k tokens). Try /context-map to see what's consuming space.`,
190
195
  "info",
191
196
  );
192
197
  }
193
198
  });
194
199
 
195
- // Auto-refresh after each assistant message if server is running
196
200
  pi.on("message_end", async (_event: any) => {
197
201
  if (_event?.message?.role === "assistant" && liveServer.isRunning) {
198
202
  try {
199
203
  await runAnalysis();
200
204
  } catch {
201
- // Silently ignore auto-refresh failures
205
+ // Ignore auto-refresh failures
202
206
  }
203
207
  }
204
208
  });
205
209
 
206
- // Graceful shutdown
207
210
  pi.on("session_shutdown", () => {
208
211
  liveServer.stop();
209
212
  });
210
213
 
211
- // Kill server when process exits
212
214
  process.on("exit", () => liveServer.stop());
213
215
  process.on("SIGINT", () => {
214
216
  liveServer.stop();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-context-map",
3
- "version": "0.5.0",
3
+ "version": "0.6.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",