pi-context-map 0.2.0 → 0.3.1

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,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.1] - 2026-06-14
4
+ ### Design & Interactivity Upgrade
5
+ - **Linear Design System**: Refactored CSS to use the Linear design tokens (canvas #010102, accent #5e6ad2) for a professional, near-black aesthetic.
6
+ - **shadcn/ui Card Patterns**: Insight cards now follow shadcn conventions (hairline borders, gradient backgrounds for severity).
7
+ - **Collapsible Insights**: Critical and warning insights are expanded by default; info insights are collapsed. Click to toggle.
8
+ - **File Search & Filter**: Added a real-time search input and status filter dropdown above the file grid. Shows match count and empty state.
9
+ - **Design Doc**: Added `docs/design.md` documenting the visual language, layout, and accessibility decisions.
10
+
11
+ ## [0.3.0] - 2026-06-14
12
+ ### Professional Context Profiler
13
+ - **Code-Aware Token Counting**: New `TokenCounter` module applies multipliers for code blocks (1.3x) and JSON (1.5x) for more accurate estimation.
14
+ - **Context Composition**: Refactored analyzer to break down context into System, Tools, History, Files, and Summaries slices.
15
+ - **Actionable Insights Engine**: New `InsightEngine` generates 6 built-in rules (tool bloat, stale files, high usage, file-heavy, summaries, system overhead).
16
+ - **Interactive HTML Report**: Stacked composition bar, color-coded insights section, and improved file cards.
17
+ - **Tool + Command**: Now registers as both a slash command (`/context-map`) and a tool for programmatic agent access.
18
+ - **Async Factory**: Updated to modern async pattern.
19
+
3
20
  ## [0.2.0] - 2026-06-14
4
21
  ### Professional Context Profiler
5
22
  - **Architectural Modernization**: Migrated to source-shipping (`./extensions`) and async factory function pattern.
package/README.md CHANGED
@@ -46,7 +46,11 @@ The extension categorizes files to help you manage context bloat:
46
46
  1. **Scanning**: The analyzer iterates through the session history, identifying all `tool_use` calls involving file operations.
47
47
  2. **Weighting**: It extracts the content length of tool results and applies a token heuristic (approx. 4 chars/token).
48
48
  3. **Categorization**: It calculates the temporal distance between the current turn and the last file access.
49
- 4. **Visualization**: It generates a standalone HTML dashboard featuring a token budget bar and a file-weight grid.
49
+ 4. **Visualization**: It generates a standalone HTML dashboard featuring a stacked composition bar, a file-weight grid with search/filter, and an interactive insights section.
50
+
51
+ ## Design
52
+
53
+ The report uses the **Linear design system** (canvas `#010102`, accent `#5e6ad2`) with **shadcn/ui card patterns**. See `docs/design.md` for the full specification. The output is a single self-contained HTML file with no external dependencies.
50
54
 
51
55
  ## Compatibility
52
56
 
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * ContextAnalyzer
3
3
  * Responsible for parsing Pi session messages to identify the active working set of files,
4
- * their token weights, and their temporal status.
4
+ * their token weights, and their temporal status. Uses the code-aware TokenCounter.
5
5
  */
6
+ import { TokenCounter } from "./token-counter";
6
7
 
7
8
  export interface FileOp {
8
9
  type: "read" | "write" | "edit" | "delete";
@@ -17,45 +18,71 @@ export interface FileContext {
17
18
  status: "active" | "stale" | "legacy";
18
19
  }
19
20
 
20
- export interface ContextMap {
21
- files: FileContext[];
22
- totalTokens: number;
23
- systemTokens: number;
24
- historyTokens: number;
25
- fileTokens: number;
26
- toolTokens: number;
21
+ export interface ContextSlice {
22
+ tokens: number;
23
+ percent: number;
27
24
  }
28
25
 
29
- export class ContextAnalyzer {
30
- /**
31
- * Heuristic for token estimation: approx 4 chars per token.
32
- */
33
- private static TOKEN_HEURISTIC = 4;
26
+ export interface ContextComposition {
27
+ system: ContextSlice;
28
+ tools: ContextSlice;
29
+ history: ContextSlice;
30
+ files: ContextSlice;
31
+ summaries: ContextSlice;
32
+ total: ContextSlice;
33
+ files_detail: FileContext[];
34
+ }
34
35
 
36
+ export class ContextAnalyzer {
35
37
  /**
36
- * Analyze session messages to produce a context map.
38
+ * Analyze session messages to produce a structured ContextComposition.
37
39
  * @param messages The full session conversation history.
38
40
  * @param currentTurn The current turn number.
39
41
  */
40
- public analyze(messages: any[], currentTurn: number): ContextMap {
42
+ public analyzeByType(
43
+ messages: any[],
44
+ currentTurn: number,
45
+ ): ContextComposition {
41
46
  const fileRegistry = new Map<string, FileContext>();
42
- let totalTokens = 0;
43
- let fileTokens = 0;
47
+
48
+ let systemTokens = 0;
44
49
  let toolTokens = 0;
50
+ let historyTokens = 0;
51
+ let fileTokens = 0;
52
+ let summaryTokens = 0;
45
53
 
46
54
  messages.forEach((msg, index) => {
47
55
  const turn = index + 1;
48
56
 
49
- // Basic token estimation for the message
50
- const msgText =
51
- typeof msg.content === "string"
52
- ? msg.content
53
- : JSON.stringify(msg.content);
54
- const msgTokens = Math.ceil(
55
- msgText.length / ContextAnalyzer.TOKEN_HEURISTIC,
56
- );
57
- totalTokens += msgTokens;
57
+ // 1. Categorize and count
58
+ if (msg.role === "system") {
59
+ systemTokens += TokenCounter.countMessage(msg);
60
+ return;
61
+ }
58
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)
69
+ if (
70
+ msg.role === "compaction" ||
71
+ msg.type === "compaction" ||
72
+ msg.compactionEntry
73
+ ) {
74
+ summaryTokens += TokenCounter.countMessage(msg);
75
+ return;
76
+ }
77
+
78
+ if (msg.role === "user" || msg.role === "assistant") {
79
+ historyTokens += TokenCounter.countMessage(msg);
80
+ } else {
81
+ // Default to history
82
+ historyTokens += TokenCounter.countMessage(msg);
83
+ }
84
+
85
+ // 2. File tracking (only on assistant tool_use blocks)
59
86
  if (msg.role === "assistant" && Array.isArray(msg.content)) {
60
87
  for (const block of msg.content) {
61
88
  if (block.type === "tool_use") {
@@ -64,15 +91,10 @@ export class ContextAnalyzer {
64
91
 
65
92
  if (path) {
66
93
  const opType = this.getOpType(block.name);
67
-
68
- // If the file is already tracked, update it
69
-
70
- // Find the tool result for this tool use to get actual content length
71
94
  const result = this.findToolResult(messages, index, block.id);
72
95
  const content = result?.content || "";
73
- const weight = Math.ceil(
74
- String(content).length / ContextAnalyzer.TOKEN_HEURISTIC,
75
- );
96
+ const weight = TokenCounter.count(String(content));
97
+ fileTokens += weight;
76
98
 
77
99
  fileRegistry.set(path, {
78
100
  path,
@@ -88,33 +110,41 @@ export class ContextAnalyzer {
88
110
  }
89
111
  }
90
112
  }
113
+ });
91
114
 
92
- if (msg.role === "tool") {
93
- toolTokens += Math.ceil(
94
- String(msg.content).length / ContextAnalyzer.TOKEN_HEURISTIC,
95
- );
96
- }
115
+ const totalTokens =
116
+ systemTokens + toolTokens + historyTokens + fileTokens + summaryTokens;
117
+
118
+ const mk = (tokens: number): ContextSlice => ({
119
+ tokens: Math.ceil(tokens),
120
+ percent: totalTokens > 0 ? Math.round((tokens / totalTokens) * 100) : 0,
97
121
  });
98
122
 
99
- const files = Array.from(fileRegistry.values());
100
- fileTokens = files.reduce((acc, f) => acc + f.weight, 0);
123
+ const files_detail = Array.from(fileRegistry.values())
124
+ .sort((a, b) => b.weight - a.weight)
125
+ .slice(0, 100);
101
126
 
102
127
  return {
103
- files: files.sort((a, b) => b.weight - a.weight).slice(0, 100),
104
- totalTokens,
105
- systemTokens: 0, // Pi provides this via ctx, not messages
106
- historyTokens: totalTokens - fileTokens - toolTokens,
107
- fileTokens,
108
- toolTokens,
128
+ system: mk(systemTokens),
129
+ tools: mk(toolTokens),
130
+ history: mk(historyTokens),
131
+ files: mk(fileTokens),
132
+ summaries: mk(summaryTokens),
133
+ total: mk(totalTokens),
134
+ files_detail,
109
135
  };
110
136
  }
111
137
 
138
+ /** Backward-compatible wrapper. */
139
+ public analyze(messages: any[], currentTurn: number): ContextComposition {
140
+ return this.analyzeByType(messages, currentTurn);
141
+ }
142
+
112
143
  private extractPath(toolName: string, input: any): string | null {
113
144
  if (toolName === "read" || toolName === "write" || toolName === "edit") {
114
145
  return typeof input.path === "string" ? input.path : null;
115
146
  }
116
147
  if (toolName === "bash") {
117
- // Simple regex for paths in bash commands (e.g., cat path/to/file)
118
148
  const match = input.command?.match(
119
149
  /(?:cat|ls|rm|mv|cp|vi|nano)\s+([^\s;]+)/,
120
150
  );
@@ -130,7 +160,7 @@ export class ContextAnalyzer {
130
160
  case "edit":
131
161
  return "edit";
132
162
  case "bash":
133
- return "delete"; // Simplified; usually bash implies modification or deletion
163
+ return "delete";
134
164
  default:
135
165
  return "read";
136
166
  }
@@ -151,12 +181,10 @@ export class ContextAnalyzer {
151
181
  toolTurnIndex: number,
152
182
  toolId: string,
153
183
  ): any {
154
- // Look for the tool result immediately following the tool use
155
184
  for (let i = toolTurnIndex + 1; i < messages.length; i++) {
156
185
  if (messages[i].role === "tool" && messages[i].tool_call_id === toolId) {
157
186
  return messages[i];
158
187
  }
159
- // If we hit another assistant turn, the result for this specific call is likely gone/compacted
160
188
  if (messages[i].role === "assistant") break;
161
189
  }
162
190
  return null;
@@ -3,17 +3,21 @@
3
3
  * Generates a visual HTML dashboard based on the ContextMap.
4
4
  */
5
5
 
6
- import type { ContextMap } from "./analyzer";
6
+ import type { ContextComposition } from "./analyzer";
7
+ import type { Insight } from "./insights";
7
8
  import { writeFileSync, mkdirSync } from "node:fs";
8
9
  import { join } from "node:path";
9
10
  import { homedir } from "node:os";
10
11
 
11
12
  export class ReportGenerator {
12
- public static generateHTML(map: ContextMap): string {
13
- const fileCards = map.files
13
+ public static generateHTML(
14
+ composition: ContextComposition,
15
+ insights: Insight[],
16
+ ): string {
17
+ const fileCards = composition.files_detail
14
18
  .map(
15
19
  (file) => `
16
- <div class="file-card ${file.status}">
20
+ <div class="file-card ${file.status}" data-path="${ReportGenerator.escapeHtml(file.path)}" data-status="${file.status}">
17
21
  <div class="file-header">
18
22
  <span class="file-path">${ReportGenerator.escapeHtml(file.path)}</span>
19
23
  <span class="file-weight">${file.weight.toLocaleString()} tokens</span>
@@ -31,7 +35,25 @@ export class ReportGenerator {
31
35
  )
32
36
  .join("");
33
37
 
34
- const budgetPercent = (map.fileTokens / map.totalTokens) * 100;
38
+ const insightCards = insights
39
+ .map((insight, i) => {
40
+ // Critical and warning are expanded by default; info is collapsed
41
+ const isCollapsed = insight.severity === "info" ? " collapsed" : "";
42
+ return `
43
+ <div class="insight-card ${insight.severity}${isCollapsed}">
44
+ <button class="insight-header" data-toggle="insight-${i}" aria-expanded="${isCollapsed ? "false" : "true"}">
45
+ <svg class="insight-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4,6 8,10 12,6"/></svg>
46
+ <span class="insight-severity">${insight.severity.toUpperCase()}</span>
47
+ <span class="insight-title">${ReportGenerator.escapeHtml(insight.title)}</span>
48
+ </button>
49
+ <div class="insight-body">
50
+ ${ReportGenerator.escapeHtml(insight.message)}
51
+ ${insight.command ? `<div class="insight-command">Suggested: <code>${insight.command}</code></div>` : ""}
52
+ </div>
53
+ </div>
54
+ `;
55
+ })
56
+ .join("");
35
57
 
36
58
  return `
37
59
  <!DOCTYPE html>
@@ -39,19 +61,333 @@ export class ReportGenerator {
39
61
  <head>
40
62
  <meta charset="UTF-8">
41
63
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
42
- <title>Pi Context Map</title>
64
+ <title>Pi Context Profiler</title>
43
65
  <style>
66
+ /* ============================================
67
+ pi-context-map Report — Design Tokens
68
+ Based on Linear design system + shadcn/ui card patterns
69
+ ============================================ */
44
70
  :root {
45
- --bg: #0f172a;
46
- --card-bg: #1e293b;
47
- --text: #f1f5f9;
48
- --text-dim: #94a3b8;
49
- --primary: #38bdf8;
50
- --active: #22c55e;
51
- --stale: #eab308;
52
- --legacy: #ef4444;
53
- --border: #334155;
71
+ /* Surfaces */
72
+ --canvas: #010102;
73
+ --surface-1: #0f1011;
74
+ --surface-2: #141516;
75
+ --surface-3: #18191a;
76
+ --hairline: #23252a;
77
+ --hairline-strong: #34343a;
78
+
79
+ /* Text */
80
+ --ink: #f7f8f8;
81
+ --ink-muted: #d0d6e0;
82
+ --ink-subtle: #8a8f98;
83
+ --ink-tertiary: #62666d;
84
+
85
+ /* Accent */
86
+ --accent: #5e6ad2;
87
+ --accent-hover: #828fff;
88
+ --accent-soft: rgba(94, 106, 210, 0.12);
89
+
90
+ /* Semantic */
91
+ --success: #27a644;
92
+ --warning: #eab308;
93
+ --danger: #ef4444;
94
+ --warning-soft: rgba(234, 179, 8, 0.10);
95
+ --danger-soft: rgba(239, 68, 68, 0.10);
96
+
97
+ /* Composition segments */
98
+ --seg-system: #6366f1;
99
+ --seg-tools: #ec4899;
100
+ --seg-history: #a855f7;
101
+ --seg-files: #38bdf8;
102
+ --seg-summaries: #14b8a6;
103
+ }
104
+
105
+ * { box-sizing: border-box; margin: 0; padding: 0; }
106
+
107
+ body {
108
+ background: var(--canvas);
109
+ color: var(--ink);
110
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
111
+ font-size: 14px;
112
+ line-height: 1.5;
113
+ -webkit-font-smoothing: antialiased;
114
+ }
115
+
116
+ .container { max-width: 1200px; margin: 0 auto; padding: 48px 32px; }
117
+
118
+ /* ===== Header ===== */
119
+ header { margin-bottom: 48px; }
120
+ h1 {
121
+ font-size: 32px;
122
+ font-weight: 600;
123
+ letter-spacing: -0.8px;
124
+ margin-bottom: 8px;
125
+ color: var(--ink);
126
+ }
127
+ .subtitle { color: var(--ink-subtle); font-size: 14px; margin-bottom: 32px; }
128
+
129
+ .stats-grid {
130
+ display: grid;
131
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
132
+ gap: 16px;
133
+ }
134
+ .stat-card {
135
+ background: var(--surface-1);
136
+ border: 1px solid var(--hairline);
137
+ border-radius: 6px;
138
+ padding: 20px;
139
+ text-align: left;
140
+ }
141
+ .stat-value {
142
+ font-size: 24px;
143
+ font-weight: 600;
144
+ color: var(--ink);
145
+ display: block;
146
+ font-variant-numeric: tabular-nums;
147
+ }
148
+ .stat-label {
149
+ color: var(--ink-subtle);
150
+ font-size: 12px;
151
+ text-transform: uppercase;
152
+ letter-spacing: 0.5px;
153
+ margin-top: 4px;
154
+ display: block;
155
+ }
156
+
157
+ /* ===== Composition Bar ===== */
158
+ .composition-container {
159
+ background: var(--surface-1);
160
+ border: 1px solid var(--hairline);
161
+ border-radius: 6px;
162
+ padding: 20px;
163
+ margin-top: 24px;
164
+ }
165
+ .composition-bar {
166
+ height: 32px;
167
+ background: var(--surface-3);
168
+ border-radius: 4px;
169
+ display: flex;
170
+ overflow: hidden;
171
+ margin-bottom: 12px;
172
+ }
173
+ .composition-segment {
174
+ height: 100%;
175
+ transition: opacity 0.2s ease;
176
+ cursor: default;
177
+ }
178
+ .composition-segment:hover { opacity: 0.85; }
179
+ .seg-system { background: var(--seg-system); }
180
+ .seg-tools { background: var(--seg-tools); }
181
+ .seg-history { background: var(--seg-history); }
182
+ .seg-files { background: var(--seg-files); }
183
+ .seg-summaries { background: var(--seg-summaries); }
184
+
185
+ .composition-legend {
186
+ display: grid;
187
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
188
+ gap: 8px;
189
+ font-size: 12px;
190
+ }
191
+ .legend-item {
192
+ display: flex;
193
+ align-items: center;
194
+ gap: 8px;
195
+ color: var(--ink-muted);
196
+ font-variant-numeric: tabular-nums;
197
+ }
198
+ .dot { width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0; }
199
+
200
+ /* ===== Sections ===== */
201
+ h2 {
202
+ font-size: 20px;
203
+ font-weight: 600;
204
+ color: var(--ink);
205
+ margin: 48px 0 16px;
206
+ letter-spacing: -0.3px;
207
+ }
208
+ h3 {
209
+ font-size: 12px;
210
+ font-weight: 500;
211
+ color: var(--ink-subtle);
212
+ text-transform: uppercase;
213
+ letter-spacing: 0.8px;
214
+ margin-bottom: 12px;
215
+ }
216
+
217
+ /* ===== Insights (shadcn-style cards) ===== */
218
+ .insight-card {
219
+ background: var(--surface-1);
220
+ border: 1px solid var(--hairline);
221
+ border-left: 3px solid var(--accent);
222
+ border-radius: 6px;
223
+ margin-bottom: 8px;
224
+ overflow: hidden;
225
+ }
226
+ .insight-card.critical { border-left-color: var(--danger); background: linear-gradient(90deg, var(--danger-soft) 0%, var(--surface-1) 100%); }
227
+ .insight-card.warning { border-left-color: var(--warning); background: linear-gradient(90deg, var(--warning-soft) 0%, var(--surface-1) 100%); }
228
+ .insight-card.info { border-left-color: var(--accent); }
229
+
230
+ .insight-header {
231
+ display: flex;
232
+ align-items: center;
233
+ gap: 12px;
234
+ padding: 14px 16px;
235
+ cursor: pointer;
236
+ user-select: none;
237
+ background: none;
238
+ border: none;
239
+ width: 100%;
240
+ text-align: left;
241
+ color: inherit;
242
+ font: inherit;
243
+ }
244
+ .insight-header:hover { background: var(--surface-2); }
245
+ .insight-chevron {
246
+ width: 16px;
247
+ height: 16px;
248
+ transition: transform 0.2s ease;
249
+ color: var(--ink-subtle);
250
+ flex-shrink: 0;
251
+ }
252
+ .insight-card.collapsed .insight-chevron { transform: rotate(-90deg); }
253
+ .insight-severity {
254
+ font-size: 10px;
255
+ font-weight: 700;
256
+ padding: 3px 8px;
257
+ border-radius: 3px;
258
+ background: var(--surface-3);
259
+ color: var(--ink-muted);
260
+ letter-spacing: 0.5px;
261
+ flex-shrink: 0;
262
+ }
263
+ .insight-card.critical .insight-severity { color: var(--danger); }
264
+ .insight-card.warning .insight-severity { color: var(--warning); }
265
+ .insight-card.info .insight-severity { color: var(--accent); }
266
+ .insight-title { font-weight: 600; color: var(--ink); font-size: 14px; }
267
+ .insight-body {
268
+ padding: 0 16px 14px 44px;
269
+ color: var(--ink-muted);
270
+ font-size: 13px;
271
+ line-height: 1.6;
272
+ }
273
+ .insight-card.collapsed .insight-body { display: none; }
274
+ .insight-command {
275
+ margin-top: 8px;
276
+ font-size: 12px;
277
+ color: var(--ink-subtle);
278
+ }
279
+ .insight-command code {
280
+ background: var(--surface-3);
281
+ color: var(--accent-hover);
282
+ padding: 2px 6px;
283
+ border-radius: 3px;
284
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
285
+ font-size: 12px;
286
+ }
287
+
288
+ /* ===== File Controls ===== */
289
+ .file-controls {
290
+ display: flex;
291
+ gap: 12px;
292
+ margin-bottom: 16px;
293
+ flex-wrap: wrap;
294
+ }
295
+ .file-search, .file-filter {
296
+ background: var(--surface-1);
297
+ border: 1px solid var(--hairline);
298
+ border-radius: 6px;
299
+ padding: 8px 12px;
300
+ color: var(--ink);
301
+ font: inherit;
302
+ font-size: 13px;
303
+ outline: none;
304
+ transition: border-color 0.15s ease;
305
+ }
306
+ .file-search:focus, .file-filter:focus { border-color: var(--accent); }
307
+ .file-search { flex: 1; min-width: 200px; }
308
+ .file-search::placeholder { color: var(--ink-tertiary); }
309
+ .file-filter { cursor: pointer; }
310
+ .file-count { color: var(--ink-subtle); font-size: 12px; padding: 8px 0; align-self: center; }
311
+
312
+ /* ===== File Grid ===== */
313
+ .file-grid {
314
+ display: grid;
315
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
316
+ gap: 12px;
54
317
  }
318
+ .file-card {
319
+ background: var(--surface-1);
320
+ border: 1px solid var(--hairline);
321
+ border-radius: 6px;
322
+ padding: 14px 16px;
323
+ transition: border-color 0.15s ease;
324
+ }
325
+ .file-card:hover { border-color: var(--hairline-strong); }
326
+ .file-card.hidden { display: none; }
327
+ .file-header {
328
+ display: flex;
329
+ justify-content: space-between;
330
+ align-items: flex-start;
331
+ gap: 8px;
332
+ margin-bottom: 10px;
333
+ }
334
+ .file-path {
335
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
336
+ font-size: 12px;
337
+ color: var(--ink);
338
+ word-break: break-all;
339
+ line-height: 1.4;
340
+ }
341
+ .file-weight {
342
+ font-size: 11px;
343
+ color: var(--ink-subtle);
344
+ white-space: nowrap;
345
+ font-variant-numeric: tabular-nums;
346
+ flex-shrink: 0;
347
+ }
348
+ .file-footer {
349
+ display: flex;
350
+ justify-content: space-between;
351
+ align-items: center;
352
+ font-size: 11px;
353
+ color: var(--ink-subtle);
354
+ text-transform: uppercase;
355
+ letter-spacing: 0.5px;
356
+ }
357
+ .op-badge {
358
+ background: var(--surface-3);
359
+ padding: 2px 6px;
360
+ border-radius: 3px;
361
+ color: var(--ink-muted);
362
+ }
363
+ .status-text { font-weight: 700; }
364
+ .file-card.active { border-left: 3px solid var(--success); }
365
+ .file-card.active .status-text { color: var(--success); }
366
+ .file-card.stale { border-left: 3px solid var(--warning); }
367
+ .file-card.stale .status-text { color: var(--warning); }
368
+ .file-card.legacy { border-left: 3px solid var(--danger); }
369
+ .file-card.legacy .status-text { color: var(--danger); }
370
+
371
+ .weight-bar {
372
+ height: 3px;
373
+ background: var(--surface-3);
374
+ border-radius: 2px;
375
+ margin-top: 10px;
376
+ overflow: hidden;
377
+ }
378
+ .weight-fill {
379
+ height: 100%;
380
+ background: var(--accent);
381
+ transition: width 0.3s ease;
382
+ }
383
+
384
+ .empty-state {
385
+ text-align: center;
386
+ padding: 48px 16px;
387
+ color: var(--ink-subtle);
388
+ font-size: 13px;
389
+ }
390
+ </style>
55
391
  body {
56
392
  background: var(--bg);
57
393
  color: var(--text);
@@ -79,36 +415,67 @@ export class ReportGenerator {
79
415
  .stat-value { font-size: 1.5rem; font-weight: bold; display: block; }
80
416
  .stat-label { color: var(--text-dim); font-size: 0.875rem; text-transform: uppercase; }
81
417
 
82
- .budget-container {
418
+ .composition-container {
83
419
  margin: 2rem 0;
84
420
  background: var(--card-bg);
85
- padding: 1rem;
421
+ padding: 1.5rem;
86
422
  border-radius: 12px;
87
423
  border: 1px solid var(--border);
88
424
  }
89
- .budget-bar {
90
- height: 24px;
425
+ .composition-bar {
426
+ height: 32px;
91
427
  background: #020617;
92
- border-radius: 12px;
428
+ border-radius: 8px;
93
429
  display: flex;
94
430
  overflow: hidden;
95
- margin-bottom: 0.5rem;
431
+ margin-bottom: 1rem;
96
432
  }
97
- .budget-segment { height: 100%; transition: width 0.3s ease; }
433
+ .composition-segment { height: 100%; transition: width 0.3s ease; }
98
434
  .seg-system { background: #6366f1; }
435
+ .seg-tools { background: #ec4899; }
99
436
  .seg-history { background: #a855f7; }
100
437
  .seg-files { background: var(--primary); }
101
- .seg-tools { background: #ec4899; }
102
-
103
- .budget-legend {
104
- display: flex;
105
- gap: 1rem;
106
- justify-content: center;
107
- font-size: 0.75rem;
438
+ .seg-summaries { background: #14b8a6; }
439
+
440
+ .composition-legend {
441
+ display: grid;
442
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
443
+ gap: 0.75rem;
444
+ font-size: 0.8rem;
108
445
  color: var(--text-dim);
109
446
  }
110
447
  .legend-item { display: flex; align-items: center; gap: 0.5rem; }
111
- .dot { width: 8px; height: 8px; border-radius: 50%; }
448
+ .dot { width: 10px; height: 10px; border-radius: 50%; }
449
+
450
+ .insights-section { margin: 2rem 0; }
451
+ .insight-card {
452
+ background: var(--card-bg);
453
+ border: 1px solid var(--border);
454
+ border-left: 4px solid var(--primary);
455
+ border-radius: 8px;
456
+ padding: 1rem 1.25rem;
457
+ margin-bottom: 0.75rem;
458
+ }
459
+ .insight-card.info { border-left-color: var(--primary); }
460
+ .insight-card.warning { border-left-color: var(--stale); }
461
+ .insight-card.critical { border-left-color: var(--legacy); }
462
+ .insight-header { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; }
463
+ .insight-severity {
464
+ font-size: 0.7rem;
465
+ font-weight: bold;
466
+ padding: 2px 8px;
467
+ border-radius: 4px;
468
+ background: rgba(255,255,255,0.1);
469
+ }
470
+ .insight-title { font-weight: 600; }
471
+ .insight-body { color: var(--text); font-size: 0.9rem; }
472
+ .insight-command { margin-top: 0.5rem; font-size: 0.8rem; color: var(--text-dim); }
473
+ .insight-command code {
474
+ background: rgba(0,0,0,0.3);
475
+ padding: 2px 6px;
476
+ border-radius: 4px;
477
+ font-family: 'Fira Code', monospace;
478
+ }
112
479
 
113
480
  .file-grid {
114
481
  display: grid;
@@ -181,48 +548,116 @@ export class ReportGenerator {
181
548
  <body>
182
549
  <div class="container">
183
550
  <header>
184
- <h1>Pi Context Map</h1>
185
- <p style="color: var(--text-dim)">Session context window visualization and token distribution.</p>
186
-
551
+ <h1>Pi Context Profiler</h1>
552
+ <p style="color: var(--text-dim)">Professional session context window analysis with actionable insights.</p>
553
+
187
554
  <div class="stats-grid">
188
555
  <div class="stat-card">
189
- <span class="stat-value">${map.totalTokens.toLocaleString()}</span>
556
+ <span class="stat-value">${composition.total.tokens.toLocaleString()}</span>
190
557
  <span class="stat-label">Total Tokens</span>
191
558
  </div>
192
559
  <div class="stat-card">
193
- <span class="stat-value">${map.files.length}</span>
560
+ <span class="stat-value">${composition.files_detail.length}</span>
194
561
  <span class="stat-label">Files in Context</span>
195
562
  </div>
196
563
  <div class="stat-card">
197
- <span class="stat-value">${map.fileTokens.toLocaleString()}</span>
198
- <span class="stat-label">File Tokens</span>
564
+ <span class="stat-value">${composition.tools.tokens.toLocaleString()}</span>
565
+ <span class="stat-label">Tool Tokens</span>
199
566
  </div>
200
567
  <div class="stat-card">
201
- <span class="stat-value">${Math.round(budgetPercent)}%</span>
202
- <span class="stat-label">File Load</span>
568
+ <span class="stat-value">${Math.round((composition.total.tokens / 128000) * 100)}%</span>
569
+ <span class="stat-label">Of 128k Window</span>
203
570
  </div>
204
571
  </div>
205
572
 
206
- <div class="budget-container">
207
- <div class="budget-bar">
208
- <div class="budget-segment seg-system" style="width: ${(map.systemTokens / map.totalTokens) * 100 || 0}%"></div>
209
- <div class="budget-segment seg-history" style="width: ${(map.historyTokens / map.totalTokens) * 100 || 0}%"></div>
210
- <div class="budget-segment seg-files" style="width: ${(map.fileTokens / map.totalTokens) * 100 || 0}%"></div>
211
- <div class="budget-segment seg-tools" style="width: ${(map.toolTokens / map.totalTokens) * 100 || 0}%"></div>
573
+ <div class="composition-container">
574
+ <h3 style="margin-top: 0; color: var(--text-dim); font-size: 0.9rem; text-transform: uppercase;">Context Composition</h3>
575
+ <div class="composition-bar">
576
+ <div class="composition-segment seg-system" style="width: ${composition.system.percent}%" title="System: ${composition.system.percent}%"></div>
577
+ <div class="composition-segment seg-tools" style="width: ${composition.tools.percent}%" title="Tools: ${composition.tools.percent}%"></div>
578
+ <div class="composition-segment seg-history" style="width: ${composition.history.percent}%" title="History: ${composition.history.percent}%"></div>
579
+ <div class="composition-segment seg-files" style="width: ${composition.files.percent}%" title="Files: ${composition.files.percent}%"></div>
580
+ <div class="composition-segment seg-summaries" style="width: ${composition.summaries.percent}%" title="Summaries: ${composition.summaries.percent}%"></div>
212
581
  </div>
213
- <div class="budget-legend">
214
- <div class="legend-item"><span class="dot seg-system"></span> System</div>
215
- <div class="legend-item"><span class="dot seg-history"></span> History</div>
216
- <div class="legend-item"><span class="dot seg-files"></span> Files</div>
217
- <div class="legend-item"><span class="dot seg-tools"></span> Tools</div>
582
+ <div class="composition-legend">
583
+ <div class="legend-item"><span class="dot seg-system"></span> System (${composition.system.percent}%)</div>
584
+ <div class="legend-item"><span class="dot seg-tools"></span> Tools (${composition.tools.percent}%)</div>
585
+ <div class="legend-item"><span class="dot seg-history"></span> History (${composition.history.percent}%)</div>
586
+ <div class="legend-item"><span class="dot seg-files"></span> Files (${composition.files.percent}%)</div>
587
+ <div class="legend-item"><span class="dot seg-summaries"></span> Summaries (${composition.summaries.percent}%)</div>
218
588
  </div>
219
589
  </div>
220
590
  </header>
221
591
 
222
- <div class="file-grid">
223
- ${fileCards}
224
- </div>
592
+ <section class="insights-section">
593
+ <h2>Actionable Insights</h2>
594
+ ${insightCards}
595
+ </section>
596
+
597
+ <section>
598
+ <h2>Files in Context</h2>
599
+ <div class="file-controls">
600
+ <input type="text" class="file-search" id="fileSearch" placeholder="Search files by path..." aria-label="Search files" />
601
+ <select class="file-filter" id="fileFilter" aria-label="Filter by status">
602
+ <option value="all">All statuses</option>
603
+ <option value="active">Active</option>
604
+ <option value="stale">Stale</option>
605
+ <option value="legacy">Legacy</option>
606
+ </select>
607
+ <span class="file-count" id="fileCount"></span>
608
+ </div>
609
+ <div class="file-grid" id="fileGrid">
610
+ ${fileCards}
611
+ </div>
612
+ <div class="empty-state" id="emptyState" style="display: none;">No files match your search.</div>
613
+ </section>
225
614
  </div>
615
+
616
+ <script>
617
+ (function() {
618
+ // ===== Insight collapse/expand =====
619
+ document.querySelectorAll('.insight-header[data-toggle]').forEach(function(btn) {
620
+ btn.addEventListener('click', function() {
621
+ var card = btn.closest('.insight-card');
622
+ var isCollapsed = card.classList.toggle('collapsed');
623
+ btn.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true');
624
+ });
625
+ });
626
+
627
+ // ===== File search & filter =====
628
+ var search = document.getElementById('fileSearch');
629
+ var filter = document.getElementById('fileFilter');
630
+ var grid = document.getElementById('fileGrid');
631
+ var count = document.getElementById('fileCount');
632
+ var empty = document.getElementById('emptyState');
633
+ var cards = Array.prototype.slice.call(grid.querySelectorAll('.file-card'));
634
+ var total = cards.length;
635
+
636
+ function applyFilters() {
637
+ var query = (search.value || '').toLowerCase();
638
+ var status = filter.value;
639
+ var visible = 0;
640
+ cards.forEach(function(card) {
641
+ var path = (card.getAttribute('data-path') || '').toLowerCase();
642
+ var cardStatus = card.getAttribute('data-status') || '';
643
+ var matchQuery = !query || path.indexOf(query) !== -1;
644
+ var matchStatus = status === 'all' || cardStatus === status;
645
+ if (matchQuery && matchStatus) {
646
+ card.classList.remove('hidden');
647
+ visible++;
648
+ } else {
649
+ card.classList.add('hidden');
650
+ }
651
+ });
652
+ count.textContent = visible === total ? total + ' files' : visible + ' of ' + total + ' files';
653
+ empty.style.display = visible === 0 ? 'block' : 'none';
654
+ }
655
+
656
+ if (search) search.addEventListener('input', applyFilters);
657
+ if (filter) filter.addEventListener('change', applyFilters);
658
+ applyFilters();
659
+ })();
660
+ </script>
226
661
  </body>
227
662
  </html>
228
663
  `;
@@ -239,15 +674,15 @@ export class ReportGenerator {
239
674
  private static getOpIcon(type: string): string {
240
675
  switch (type) {
241
676
  case "read":
242
- return "👁️";
677
+ return "READ";
243
678
  case "write":
244
- return "📝";
679
+ return "WRITE";
245
680
  case "edit":
246
- return "✍️";
681
+ return "EDIT";
247
682
  case "delete":
248
- return "🗑️";
683
+ return "DELETE";
249
684
  default:
250
- return "📄";
685
+ return "FILE";
251
686
  }
252
687
  }
253
688
 
@@ -1,49 +1,42 @@
1
1
  /**
2
2
  * pi-context-map
3
- * Pi extension to visualize session context window and token distribution.
3
+ * Professional Context Profiler for Pi.
4
4
  */
5
5
 
6
6
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
7
  import { ContextAnalyzer } from "./analyzer";
8
8
  import { ReportGenerator } from "./generator";
9
+ import { InsightEngine } from "./insights";
9
10
 
10
- export default function (pi: ExtensionAPI) {
11
+ export default async function piContextMap(pi: ExtensionAPI) {
11
12
  const analyzer = new ContextAnalyzer();
12
13
 
13
- // Register the /context-map command
14
+ async function runAnalysis() {
15
+ const messages = (pi as any).session?.messages || [];
16
+ const currentTurn = messages.length;
17
+ const composition = analyzer.analyzeByType(messages, currentTurn);
18
+ const insights = InsightEngine.generate(composition);
19
+ const html = ReportGenerator.generateHTML(composition, insights);
20
+ const reportPath = ReportGenerator.writeReport(html);
21
+ return { composition, insights, reportPath };
22
+ }
23
+
14
24
  pi.registerCommand("context-map", {
15
- description: "Generate a visual map of the current session context window.",
16
- handler: (_args, ctx) => {
25
+ description: "Generate a visual context map with actionable insights.",
26
+ handler: async (_args: any, ctx: any) => {
17
27
  ctx.ui.notify("Analyzing session context...", "info");
18
-
19
28
  try {
20
- // 1. Extract messages and current turn
21
- // Note: We assume ctx.session.messages is available.
22
- // If not, we may need to fetch them via another API or use provided event data.
23
- const messages = ctx.session.messages || [];
24
- const currentTurn = messages.length;
25
-
26
- if (messages.length === 0) {
27
- ctx.ui.notify("No session history found to map.", "warning");
28
- return;
29
- }
30
-
31
- // 2. Analyze context
32
- const map = analyzer.analyze(messages, currentTurn);
33
-
34
- // 3. Generate HTML Report
35
- const html = ReportGenerator.generateHTML(map);
36
- const reportPath = ReportGenerator.writeReport(html);
37
-
38
- ctx.ui.notify(
39
- `Context map generated successfully! \nPath: ${reportPath}`,
40
- "success",
41
- );
42
-
43
- // Providing a link or instruction to open the report
29
+ const { reportPath, insights } = await runAnalysis();
30
+ const criticalCount = insights.filter(
31
+ (i) => i.severity === "critical",
32
+ ).length;
33
+ const summary =
34
+ criticalCount > 0
35
+ ? `Context map generated. ${criticalCount} critical insight(s) found.`
36
+ : `Context map generated successfully.`;
44
37
  ctx.ui.notify(
45
- "You can open the report.html in your browser to see the visualization.",
46
- "info",
38
+ `${summary} Path: ${reportPath}`,
39
+ criticalCount > 0 ? "warning" : "success",
47
40
  );
48
41
  } catch (error) {
49
42
  const message = error instanceof Error ? error.message : String(error);
@@ -52,12 +45,49 @@ export default function (pi: ExtensionAPI) {
52
45
  },
53
46
  });
54
47
 
55
- // Optional: Notify the user when a significant amount of context is loaded
56
- pi.on("session_before_compact", (event, ctx) => {
57
- const { preparation } = event;
58
- const tokens = preparation.tokensBefore;
48
+ pi.registerTool({
49
+ name: "context-map",
50
+ description:
51
+ "Analyze the current session context composition and return actionable insights.",
52
+ parameters: {
53
+ type: "object",
54
+ properties: {},
55
+ },
56
+ handler: async (_ctx: any, _args: any) => {
57
+ try {
58
+ const { composition, insights } = await runAnalysis();
59
+ const summary =
60
+ `Context: ${composition.total.tokens.toLocaleString()} tokens total. ` +
61
+ `System ${composition.system.percent}%, Tools ${composition.tools.percent}%, ` +
62
+ `History ${composition.history.percent}%, Files ${composition.files.percent}%, ` +
63
+ `Summaries ${composition.summaries.percent}%. ` +
64
+ `${insights.length} insight(s) generated.`;
65
+ return {
66
+ summary,
67
+ composition: {
68
+ system: composition.system.tokens,
69
+ tools: composition.tools.tokens,
70
+ history: composition.history.tokens,
71
+ files: composition.files.tokens,
72
+ summaries: composition.summaries.tokens,
73
+ total: composition.total.tokens,
74
+ },
75
+ insights: insights.map((i) => ({
76
+ severity: i.severity,
77
+ title: i.title,
78
+ message: i.message,
79
+ command: i.command,
80
+ })),
81
+ };
82
+ } catch (error: any) {
83
+ return { error: error.message };
84
+ }
85
+ },
86
+ });
59
87
 
60
- if (tokens > 100_000) {
88
+ pi.on("session_before_compact", (event: any, ctx: any) => {
89
+ const tokens = (event as any).preparation?.tokensBefore;
90
+ if (tokens && tokens > 100_000) {
61
91
  ctx.ui.notify(
62
92
  `High context load detected (${(tokens / 1000).toFixed(1)}k tokens). Try /context-map to see what's consuming space.`,
63
93
  "info",
@@ -0,0 +1,114 @@
1
+ /**
2
+ * InsightEngine
3
+ * Generates actionable recommendations based on the ContextComposition.
4
+ */
5
+ import type { ContextComposition } from "./analyzer";
6
+
7
+ export type InsightSeverity = "info" | "warning" | "critical";
8
+
9
+ export interface Insight {
10
+ id: string;
11
+ severity: InsightSeverity;
12
+ title: string;
13
+ message: string;
14
+ command?: string; // Suggested slash command
15
+ }
16
+
17
+ const TYPICAL_WINDOW = 128_000; // Default context window size (Claude Sonnet/Opus)
18
+
19
+ export class InsightEngine {
20
+ /**
21
+ * Generate a list of insights based on the composition.
22
+ */
23
+ public static generate(composition: ContextComposition): Insight[] {
24
+ const insights: Insight[] = [];
25
+ const { system, tools, history, files, summaries, total } = composition;
26
+
27
+ // Rule 1: Tool bloat
28
+ if (tools.percent > 40) {
29
+ insights.push({
30
+ id: "tool-bloat",
31
+ severity: "warning",
32
+ title: "Tool results dominate context",
33
+ message: `Tool results account for ${tools.percent}% of your context (${tools.tokens.toLocaleString()} tokens). Consider compacting or trimming verbose tool outputs.`,
34
+ command: "/ultra-compact",
35
+ });
36
+ }
37
+
38
+ // Rule 2: Stale files
39
+ const staleFiles = composition.files_detail.filter(
40
+ (f) => f.status === "legacy",
41
+ );
42
+ if (staleFiles.length > 0) {
43
+ const totalStaleTokens = staleFiles.reduce((sum, f) => sum + f.weight, 0);
44
+ insights.push({
45
+ id: "stale-files",
46
+ severity: staleFiles.length > 5 ? "warning" : "info",
47
+ title: `${staleFiles.length} stale file(s) in context`,
48
+ message: `Files accessed more than 10 turns ago are still in context (~${totalStaleTokens.toLocaleString()} tokens). They are unlikely to be needed.`,
49
+ });
50
+ }
51
+
52
+ // Rule 3: High overall usage
53
+ const usagePercent = Math.round((total.tokens / TYPICAL_WINDOW) * 100);
54
+ if (usagePercent > 80) {
55
+ insights.push({
56
+ id: "high-usage",
57
+ severity: "critical",
58
+ title: "Context window nearly full",
59
+ message: `You are at ${usagePercent}% of a typical 128k context window. Compaction or summarization is strongly recommended.`,
60
+ command: "/ultra-compact",
61
+ });
62
+ } else if (usagePercent > 60) {
63
+ insights.push({
64
+ id: "moderate-usage",
65
+ severity: "warning",
66
+ title: "Context usage is high",
67
+ message: `You are at ${usagePercent}% of a typical 128k context window. Plan to compact before adding more files.`,
68
+ });
69
+ }
70
+
71
+ // Rule 4: File-heavy context
72
+ if (files.percent > 30) {
73
+ insights.push({
74
+ id: "file-heavy",
75
+ severity: "info",
76
+ title: "Many files loaded",
77
+ message: `Files account for ${files.percent}% of context (${files.tokens.toLocaleString()} tokens). Consider using smart-read to load only the relevant symbols.`,
78
+ command: "/smart-read",
79
+ });
80
+ }
81
+
82
+ // Rule 5: Compaction summaries present
83
+ if (summaries.tokens > 0) {
84
+ insights.push({
85
+ id: "summaries-present",
86
+ severity: "info",
87
+ title: "Compaction summaries detected",
88
+ message: `${summaries.tokens.toLocaleString()} tokens are from prior compaction summaries. Original detail has been compressed.`,
89
+ });
90
+ }
91
+
92
+ // Rule 6: System prompt overhead
93
+ if (system.percent > 25) {
94
+ insights.push({
95
+ id: "system-overhead",
96
+ severity: "info",
97
+ title: "Large system prompt",
98
+ message: `System prompt uses ${system.percent}% of context. This is normal for agents with extensive tool definitions.`,
99
+ });
100
+ }
101
+
102
+ // If everything looks good, add a positive insight
103
+ if (insights.length === 0) {
104
+ insights.push({
105
+ id: "healthy-context",
106
+ severity: "info",
107
+ title: "Context looks healthy",
108
+ message: `Your context composition is balanced and under ${usagePercent}% of a typical window.`,
109
+ });
110
+ }
111
+
112
+ return insights;
113
+ }
114
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * TokenCounter
3
+ * Code-aware token estimation. Avoids heavy external tokenizers (e.g., tiktoken)
4
+ * by applying multipliers based on content structure.
5
+ *
6
+ * Heuristic base: 4 characters per token (the standard for English text).
7
+ * Adjustments:
8
+ * - Code blocks (```...```) are denser in tokens (identifiers, symbols).
9
+ * - JSON payloads have more structural overhead.
10
+ * - Pure whitespace is under-weighted.
11
+ */
12
+ export class TokenCounter {
13
+ /** Base heuristic: average English is ~4 characters per token. */
14
+ private static BASE_CHARS_PER_TOKEN = 4;
15
+
16
+ /** Multiplier for fenced code blocks (```` ``` ````). */
17
+ private static CODE_MULTIPLIER = 1.3;
18
+
19
+ /** Multiplier for JSON-like structures. */
20
+ private static JSON_MULTIPLIER = 1.5;
21
+
22
+ /**
23
+ * Count estimated tokens for a raw string of text.
24
+ */
25
+ public static count(text: string): number {
26
+ if (!text) return 0;
27
+
28
+ let total = 0;
29
+ let cursor = 0;
30
+ const len = text.length;
31
+
32
+ while (cursor < len) {
33
+ // Detect fenced code blocks
34
+ const fenceStart = text.indexOf("```", cursor);
35
+ if (fenceStart !== -1) {
36
+ // Count everything up to the fence as regular text
37
+ total += TokenCounter.regularChunk(text.substring(cursor, fenceStart));
38
+ // Find the closing fence
39
+ const fenceEnd = text.indexOf("```", fenceStart + 3);
40
+ if (fenceEnd === -1) {
41
+ // Unclosed fence — treat the rest as code
42
+ total += TokenCounter.codeChunk(text.substring(fenceStart));
43
+ cursor = len;
44
+ } else {
45
+ total += TokenCounter.codeChunk(
46
+ text.substring(fenceStart, fenceEnd + 3),
47
+ );
48
+ cursor = fenceEnd + 3;
49
+ }
50
+ continue;
51
+ }
52
+
53
+ // Detect JSON-like content (starts with { or [ and has balanced structure)
54
+ const trimmed = text.substring(cursor).trimStart();
55
+ const firstChar = trimmed.charAt(0);
56
+ if (
57
+ (firstChar === "{" || firstChar === "[") &&
58
+ TokenCounter.looksLikeJson(trimmed)
59
+ ) {
60
+ const jsonLen = TokenCounter.extractJsonLength(trimmed);
61
+ if (jsonLen > 0) {
62
+ total += TokenCounter.jsonChunk(trimmed.substring(0, jsonLen));
63
+ cursor += text.substring(cursor).indexOf(trimmed) + jsonLen;
64
+ continue;
65
+ }
66
+ }
67
+
68
+ // Default: regular text
69
+ total += TokenCounter.regularChunk(text.substring(cursor));
70
+ cursor = len;
71
+ }
72
+
73
+ return Math.ceil(total);
74
+ }
75
+
76
+ /**
77
+ * Convenience: count tokens for any message shape Pi uses.
78
+ */
79
+ public static countMessage(msg: any): number {
80
+ if (!msg) return 0;
81
+ if (typeof msg.content === "string") {
82
+ return TokenCounter.count(msg.content);
83
+ }
84
+ if (Array.isArray(msg.content)) {
85
+ return msg.content.reduce((sum: number, block: any) => {
86
+ if (typeof block === "string") return sum + TokenCounter.count(block);
87
+ if (block.type === "text" && typeof block.text === "string") {
88
+ return sum + TokenCounter.count(block.text);
89
+ }
90
+ if (block.type === "tool_use" || block.type === "tool_result") {
91
+ return sum + TokenCounter.count(JSON.stringify(block));
92
+ }
93
+ return sum + TokenCounter.count(JSON.stringify(block));
94
+ }, 0);
95
+ }
96
+ return TokenCounter.count(JSON.stringify(msg));
97
+ }
98
+
99
+ private static regularChunk(text: string): number {
100
+ return text.length / TokenCounter.BASE_CHARS_PER_TOKEN;
101
+ }
102
+
103
+ private static codeChunk(text: string): number {
104
+ return (
105
+ (text.length / TokenCounter.BASE_CHARS_PER_TOKEN) *
106
+ TokenCounter.CODE_MULTIPLIER
107
+ );
108
+ }
109
+
110
+ private static jsonChunk(text: string): number {
111
+ return (
112
+ (text.length / TokenCounter.BASE_CHARS_PER_TOKEN) *
113
+ TokenCounter.JSON_MULTIPLIER
114
+ );
115
+ }
116
+
117
+ private static looksLikeJson(text: string): boolean {
118
+ // Quick heuristic: contains quoted keys and structural punctuation
119
+ return /"[^"]+"\s*:/i.test(text) && /[{}[\],]/.test(text);
120
+ }
121
+
122
+ private static extractJsonLength(text: string): number {
123
+ let depth = 0;
124
+ let inString = false;
125
+ let escape = false;
126
+ for (let i = 0; i < text.length; i++) {
127
+ const ch = text[i];
128
+ if (escape) {
129
+ escape = false;
130
+ continue;
131
+ }
132
+ if (ch === "\\") {
133
+ escape = true;
134
+ continue;
135
+ }
136
+ if (ch === '"') {
137
+ inString = !inString;
138
+ continue;
139
+ }
140
+ if (inString) continue;
141
+ if (ch === "{" || ch === "[") depth++;
142
+ else if (ch === "}" || ch === "]") {
143
+ depth--;
144
+ if (depth === 0) return i + 1;
145
+ }
146
+ }
147
+ return 0;
148
+ }
149
+ }
@@ -1,31 +1,3 @@
1
1
  declare module "@earendil-works/pi-coding-agent" {
2
- export interface ExtensionAPI {
3
- registerCommand(
4
- name: string,
5
- options: {
6
- description: string;
7
- handler: (
8
- args: string | undefined,
9
- ctx: ExtensionContext,
10
- ) => Promise<void> | void;
11
- },
12
- ): void;
13
- on(
14
- event: string,
15
- handler: (event: any, ctx: ExtensionContext) => Promise<void> | void,
16
- ): void;
17
- }
18
-
19
- export interface ExtensionContext {
20
- ui: {
21
- notify(
22
- message: string,
23
- level: "info" | "success" | "warning" | "error",
24
- ): void;
25
- };
26
- session: {
27
- messages: any[];
28
- };
29
- modelRegistry: any;
30
- }
2
+ export type ExtensionAPI = any;
31
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-context-map",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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",