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 +9 -0
- package/extensions/analyzer.ts +78 -50
- package/extensions/generator.ts +100 -49
- package/extensions/index.ts +61 -39
- package/extensions/insights.ts +114 -0
- package/extensions/token-counter.ts +149 -0
- package/extensions/types/pi-coding-agent.d.ts +1 -29
- package/package.json +1 -1
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.
|
package/extensions/analyzer.ts
CHANGED
|
@@ -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
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
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
|
|
42
|
+
public analyzeByType(
|
|
43
|
+
messages: any[],
|
|
44
|
+
currentTurn: number,
|
|
45
|
+
): ContextComposition {
|
|
41
46
|
const fileRegistry = new Map<string, FileContext>();
|
|
42
|
-
|
|
43
|
-
let
|
|
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
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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 =
|
|
74
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
100
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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";
|
|
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;
|
package/extensions/generator.ts
CHANGED
|
@@ -3,14 +3,18 @@
|
|
|
3
3
|
* Generates a visual HTML dashboard based on the ContextMap.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import type {
|
|
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(
|
|
13
|
-
|
|
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
|
|
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
|
-
.
|
|
99
|
+
.composition-container {
|
|
83
100
|
margin: 2rem 0;
|
|
84
101
|
background: var(--card-bg);
|
|
85
|
-
padding:
|
|
102
|
+
padding: 1.5rem;
|
|
86
103
|
border-radius: 12px;
|
|
87
104
|
border: 1px solid var(--border);
|
|
88
105
|
}
|
|
89
|
-
.
|
|
90
|
-
height:
|
|
106
|
+
.composition-bar {
|
|
107
|
+
height: 32px;
|
|
91
108
|
background: #020617;
|
|
92
|
-
border-radius:
|
|
109
|
+
border-radius: 8px;
|
|
93
110
|
display: flex;
|
|
94
111
|
overflow: hidden;
|
|
95
|
-
margin-bottom:
|
|
112
|
+
margin-bottom: 1rem;
|
|
96
113
|
}
|
|
97
|
-
.
|
|
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-
|
|
102
|
-
|
|
103
|
-
.
|
|
104
|
-
display:
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
font-size: 0.
|
|
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:
|
|
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
|
|
185
|
-
<p style="color: var(--text-dim)">
|
|
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">${
|
|
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">${
|
|
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">${
|
|
198
|
-
<span class="stat-label">
|
|
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(
|
|
202
|
-
<span class="stat-label">
|
|
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="
|
|
207
|
-
<
|
|
208
|
-
|
|
209
|
-
<div class="
|
|
210
|
-
<div class="
|
|
211
|
-
<div class="
|
|
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="
|
|
214
|
-
<div class="legend-item"><span class="dot seg-system"></span> System</div>
|
|
215
|
-
<div class="legend-item"><span class="dot seg-
|
|
216
|
-
<div class="legend-item"><span class="dot seg-
|
|
217
|
-
<div class="legend-item"><span class="dot seg-
|
|
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
|
-
|
|
243
|
-
case "
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
package/extensions/index.ts
CHANGED
|
@@ -1,50 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* pi-context-map
|
|
3
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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",
|