pi-context-map 0.2.0 → 0.3.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,14 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0] - 2026-06-14
4
+ ### Professional Context Profiler
5
+ - **Code-Aware Token Counting**: New `TokenCounter` module applies multipliers for code blocks (1.3x) and JSON (1.5x) for more accurate estimation.
6
+ - **Context Composition**: Refactored analyzer to break down context into System, Tools, History, Files, and Summaries slices.
7
+ - **Actionable Insights Engine**: New `InsightEngine` generates 6 built-in rules (tool bloat, stale files, high usage, file-heavy, summaries, system overhead).
8
+ - **Interactive HTML Report**: Stacked composition bar, color-coded insights section, and improved file cards.
9
+ - **Tool + Command**: Now registers as both a slash command (`/context-map`) and a tool for programmatic agent access.
10
+ - **Async Factory**: Updated to modern async pattern.
11
+
3
12
  ## [0.2.0] - 2026-06-14
4
13
  ### Professional Context Profiler
5
14
  - **Architectural Modernization**: Migrated to source-shipping (`./extensions`) and async factory function pattern.
@@ -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,14 +3,18 @@
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
20
  <div class="file-card ${file.status}">
@@ -31,7 +35,20 @@ export class ReportGenerator {
31
35
  )
32
36
  .join("");
33
37
 
34
- const budgetPercent = (map.fileTokens / map.totalTokens) * 100;
38
+ const insightCards = insights
39
+ .map(
40
+ (insight) => `
41
+ <div class="insight-card ${insight.severity}">
42
+ <div class="insight-header">
43
+ <span class="insight-severity">${insight.severity.toUpperCase()}</span>
44
+ <span class="insight-title">${ReportGenerator.escapeHtml(insight.title)}</span>
45
+ </div>
46
+ <div class="insight-body">${ReportGenerator.escapeHtml(insight.message)}</div>
47
+ ${insight.command ? `<div class="insight-command">Suggested: <code>${insight.command}</code></div>` : ""}
48
+ </div>
49
+ `,
50
+ )
51
+ .join("");
35
52
 
36
53
  return `
37
54
  <!DOCTYPE html>
@@ -79,36 +96,67 @@ export class ReportGenerator {
79
96
  .stat-value { font-size: 1.5rem; font-weight: bold; display: block; }
80
97
  .stat-label { color: var(--text-dim); font-size: 0.875rem; text-transform: uppercase; }
81
98
 
82
- .budget-container {
99
+ .composition-container {
83
100
  margin: 2rem 0;
84
101
  background: var(--card-bg);
85
- padding: 1rem;
102
+ padding: 1.5rem;
86
103
  border-radius: 12px;
87
104
  border: 1px solid var(--border);
88
105
  }
89
- .budget-bar {
90
- height: 24px;
106
+ .composition-bar {
107
+ height: 32px;
91
108
  background: #020617;
92
- border-radius: 12px;
109
+ border-radius: 8px;
93
110
  display: flex;
94
111
  overflow: hidden;
95
- margin-bottom: 0.5rem;
112
+ margin-bottom: 1rem;
96
113
  }
97
- .budget-segment { height: 100%; transition: width 0.3s ease; }
114
+ .composition-segment { height: 100%; transition: width 0.3s ease; }
98
115
  .seg-system { background: #6366f1; }
116
+ .seg-tools { background: #ec4899; }
99
117
  .seg-history { background: #a855f7; }
100
118
  .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;
119
+ .seg-summaries { background: #14b8a6; }
120
+
121
+ .composition-legend {
122
+ display: grid;
123
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
124
+ gap: 0.75rem;
125
+ font-size: 0.8rem;
108
126
  color: var(--text-dim);
109
127
  }
110
128
  .legend-item { display: flex; align-items: center; gap: 0.5rem; }
111
- .dot { width: 8px; height: 8px; border-radius: 50%; }
129
+ .dot { width: 10px; height: 10px; border-radius: 50%; }
130
+
131
+ .insights-section { margin: 2rem 0; }
132
+ .insight-card {
133
+ background: var(--card-bg);
134
+ border: 1px solid var(--border);
135
+ border-left: 4px solid var(--primary);
136
+ border-radius: 8px;
137
+ padding: 1rem 1.25rem;
138
+ margin-bottom: 0.75rem;
139
+ }
140
+ .insight-card.info { border-left-color: var(--primary); }
141
+ .insight-card.warning { border-left-color: var(--stale); }
142
+ .insight-card.critical { border-left-color: var(--legacy); }
143
+ .insight-header { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; }
144
+ .insight-severity {
145
+ font-size: 0.7rem;
146
+ font-weight: bold;
147
+ padding: 2px 8px;
148
+ border-radius: 4px;
149
+ background: rgba(255,255,255,0.1);
150
+ }
151
+ .insight-title { font-weight: 600; }
152
+ .insight-body { color: var(--text); font-size: 0.9rem; }
153
+ .insight-command { margin-top: 0.5rem; font-size: 0.8rem; color: var(--text-dim); }
154
+ .insight-command code {
155
+ background: rgba(0,0,0,0.3);
156
+ padding: 2px 6px;
157
+ border-radius: 4px;
158
+ font-family: 'Fira Code', monospace;
159
+ }
112
160
 
113
161
  .file-grid {
114
162
  display: grid;
@@ -181,44 +229,52 @@ export class ReportGenerator {
181
229
  <body>
182
230
  <div class="container">
183
231
  <header>
184
- <h1>Pi Context Map</h1>
185
- <p style="color: var(--text-dim)">Session context window visualization and token distribution.</p>
186
-
232
+ <h1>Pi Context Profiler</h1>
233
+ <p style="color: var(--text-dim)">Professional session context window analysis with actionable insights.</p>
234
+
187
235
  <div class="stats-grid">
188
236
  <div class="stat-card">
189
- <span class="stat-value">${map.totalTokens.toLocaleString()}</span>
237
+ <span class="stat-value">${composition.total.tokens.toLocaleString()}</span>
190
238
  <span class="stat-label">Total Tokens</span>
191
239
  </div>
192
240
  <div class="stat-card">
193
- <span class="stat-value">${map.files.length}</span>
241
+ <span class="stat-value">${composition.files_detail.length}</span>
194
242
  <span class="stat-label">Files in Context</span>
195
243
  </div>
196
244
  <div class="stat-card">
197
- <span class="stat-value">${map.fileTokens.toLocaleString()}</span>
198
- <span class="stat-label">File Tokens</span>
245
+ <span class="stat-value">${composition.tools.tokens.toLocaleString()}</span>
246
+ <span class="stat-label">Tool Tokens</span>
199
247
  </div>
200
248
  <div class="stat-card">
201
- <span class="stat-value">${Math.round(budgetPercent)}%</span>
202
- <span class="stat-label">File Load</span>
249
+ <span class="stat-value">${Math.round((composition.total.tokens / 128000) * 100)}%</span>
250
+ <span class="stat-label">Of 128k Window</span>
203
251
  </div>
204
252
  </div>
205
253
 
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>
254
+ <div class="composition-container">
255
+ <h3 style="margin-top: 0; color: var(--text-dim); font-size: 0.9rem; text-transform: uppercase;">Context Composition</h3>
256
+ <div class="composition-bar">
257
+ <div class="composition-segment seg-system" style="width: ${composition.system.percent}%" title="System: ${composition.system.percent}%"></div>
258
+ <div class="composition-segment seg-tools" style="width: ${composition.tools.percent}%" title="Tools: ${composition.tools.percent}%"></div>
259
+ <div class="composition-segment seg-history" style="width: ${composition.history.percent}%" title="History: ${composition.history.percent}%"></div>
260
+ <div class="composition-segment seg-files" style="width: ${composition.files.percent}%" title="Files: ${composition.files.percent}%"></div>
261
+ <div class="composition-segment seg-summaries" style="width: ${composition.summaries.percent}%" title="Summaries: ${composition.summaries.percent}%"></div>
212
262
  </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>
263
+ <div class="composition-legend">
264
+ <div class="legend-item"><span class="dot seg-system"></span> System (${composition.system.percent}%)</div>
265
+ <div class="legend-item"><span class="dot seg-tools"></span> Tools (${composition.tools.percent}%)</div>
266
+ <div class="legend-item"><span class="dot seg-history"></span> History (${composition.history.percent}%)</div>
267
+ <div class="legend-item"><span class="dot seg-files"></span> Files (${composition.files.percent}%)</div>
268
+ <div class="legend-item"><span class="dot seg-summaries"></span> Summaries (${composition.summaries.percent}%)</div>
218
269
  </div>
219
270
  </div>
220
271
  </header>
221
272
 
273
+ <section class="insights-section">
274
+ <h2>Actionable Insights</h2>
275
+ ${insightCards}
276
+ </section>
277
+
222
278
  <div class="file-grid">
223
279
  ${fileCards}
224
280
  </div>
@@ -238,16 +294,11 @@ export class ReportGenerator {
238
294
 
239
295
  private static getOpIcon(type: string): string {
240
296
  switch (type) {
241
- case "read":
242
- return "👁️";
243
- case "write":
244
- return "📝";
245
- case "edit":
246
- return "✍️";
247
- case "delete":
248
- return "🗑️";
249
- default:
250
- return "📄";
297
+ case "read": return "READ";
298
+ case "write": return "WRITE";
299
+ case "edit": return "EDIT";
300
+ case "delete": return "DELETE";
301
+ default: return "FILE";
251
302
  }
252
303
  }
253
304
 
@@ -1,50 +1,37 @@
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
44
- ctx.ui.notify(
45
- "You can open the report.html in your browser to see the visualization.",
46
- "info",
47
- );
29
+ const { reportPath, insights } = await runAnalysis();
30
+ const criticalCount = insights.filter((i) => i.severity === "critical").length;
31
+ const summary = criticalCount > 0
32
+ ? `Context map generated. ${criticalCount} critical insight(s) found.`
33
+ : `Context map generated successfully.`;
34
+ ctx.ui.notify(`${summary} Path: ${reportPath}`, criticalCount > 0 ? "warning" : "success");
48
35
  } catch (error) {
49
36
  const message = error instanceof Error ? error.message : String(error);
50
37
  ctx.ui.notify(`Failed to generate context map: ${message}`, "error");
@@ -52,12 +39,47 @@ export default function (pi: ExtensionAPI) {
52
39
  },
53
40
  });
54
41
 
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;
42
+ pi.registerTool({
43
+ name: "context-map",
44
+ description: "Analyze the current session context composition and return actionable insights.",
45
+ parameters: {
46
+ type: "object",
47
+ properties: {},
48
+ },
49
+ handler: async (_ctx: any, _args: any) => {
50
+ try {
51
+ const { composition, insights } = await runAnalysis();
52
+ const summary = `Context: ${composition.total.tokens.toLocaleString()} tokens total. ` +
53
+ `System ${composition.system.percent}%, Tools ${composition.tools.percent}%, ` +
54
+ `History ${composition.history.percent}%, Files ${composition.files.percent}%, ` +
55
+ `Summaries ${composition.summaries.percent}%. ` +
56
+ `${insights.length} insight(s) generated.`;
57
+ return {
58
+ summary,
59
+ composition: {
60
+ system: composition.system.tokens,
61
+ tools: composition.tools.tokens,
62
+ history: composition.history.tokens,
63
+ files: composition.files.tokens,
64
+ summaries: composition.summaries.tokens,
65
+ total: composition.total.tokens,
66
+ },
67
+ insights: insights.map((i) => ({
68
+ severity: i.severity,
69
+ title: i.title,
70
+ message: i.message,
71
+ command: i.command,
72
+ })),
73
+ };
74
+ } catch (error: any) {
75
+ return { error: error.message };
76
+ }
77
+ },
78
+ });
59
79
 
60
- if (tokens > 100_000) {
80
+ pi.on("session_before_compact", (event: any, ctx: any) => {
81
+ const tokens = (event as any).preparation?.tokensBefore;
82
+ if (tokens && tokens > 100_000) {
61
83
  ctx.ui.notify(
62
84
  `High context load detected (${(tokens / 1000).toFixed(1)}k tokens). Try /context-map to see what's consuming space.`,
63
85
  "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.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",