pi-context-map 0.1.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/README.md +41 -0
- package/dist/analyzer.d.ts +40 -0
- package/dist/analyzer.js +117 -0
- package/dist/generator.d.ts +11 -0
- package/dist/generator.js +258 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +50 -0
- package/docs/proposal.md +41 -0
- package/docs/spec.md +57 -0
- package/package.json +34 -0
- package/src/analyzer.ts +164 -0
- package/src/generator.ts +264 -0
- package/src/index.ts +67 -0
- package/src/types/pi-ai.d.ts +16 -0
- package/src/types/pi-coding-agent.d.ts +31 -0
- package/tasks.md +32 -0
- package/tsconfig.json +20 -0
package/README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# pi-context-map
|
|
2
|
+
|
|
3
|
+
A Pi extension that transforms your session's context window into a visual, actionable dashboard.
|
|
4
|
+
|
|
5
|
+
## 🚀 Features
|
|
6
|
+
|
|
7
|
+
- **Visual Context Budget**: See exactly how your tokens are distributed between system prompts, history, files, and tool results.
|
|
8
|
+
- **Working Set Analysis**: Identify which files are "Active", "Stale", or "Legacy".
|
|
9
|
+
- **Token Weighting**: Discover which files are consuming the most context window space.
|
|
10
|
+
- **Operation History**: Track how files entered the context (Read 👁️, Write 📝, Edit ✍️).
|
|
11
|
+
- **Temporal Mapping**: See a timeline of file access to identify candidates for compaction.
|
|
12
|
+
|
|
13
|
+
## 🛠️ Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pi install npm:pi-context-map
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## 📖 Usage
|
|
20
|
+
|
|
21
|
+
Run the following command in any Pi session:
|
|
22
|
+
|
|
23
|
+
`/context-map`
|
|
24
|
+
|
|
25
|
+
The extension will analyze your session and generate a standalone HTML report at:
|
|
26
|
+
`~/.pi/context-map/report.html`
|
|
27
|
+
|
|
28
|
+
## 📊 How it Works
|
|
29
|
+
|
|
30
|
+
The extension scans the session's message history to build a map of the "Working Set":
|
|
31
|
+
1. **Scanning**: Every `tool_use` call for `read`, `write`, or `edit` is tracked.
|
|
32
|
+
2. **Weighting**: Content length is converted to estimated tokens.
|
|
33
|
+
3. **Categorization**:
|
|
34
|
+
- **Active**: Accessed in the last 3 turns.
|
|
35
|
+
- **Stale**: Accessed in the last 10 turns.
|
|
36
|
+
- **Legacy**: Accessed > 10 turns ago.
|
|
37
|
+
4. **Visualization**: Data is injected into a high-performance HTML dashboard.
|
|
38
|
+
|
|
39
|
+
## ⚖️ License
|
|
40
|
+
|
|
41
|
+
MIT
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ContextAnalyzer
|
|
3
|
+
* Responsible for parsing Pi session messages to identify the active working set of files,
|
|
4
|
+
* their token weights, and their temporal status.
|
|
5
|
+
*/
|
|
6
|
+
export interface FileOp {
|
|
7
|
+
type: "read" | "write" | "edit" | "delete";
|
|
8
|
+
turn: number;
|
|
9
|
+
timestamp: number;
|
|
10
|
+
}
|
|
11
|
+
export interface FileContext {
|
|
12
|
+
path: string;
|
|
13
|
+
weight: number;
|
|
14
|
+
lastOp: FileOp;
|
|
15
|
+
status: "active" | "stale" | "legacy";
|
|
16
|
+
}
|
|
17
|
+
export interface ContextMap {
|
|
18
|
+
files: FileContext[];
|
|
19
|
+
totalTokens: number;
|
|
20
|
+
systemTokens: number;
|
|
21
|
+
historyTokens: number;
|
|
22
|
+
fileTokens: number;
|
|
23
|
+
toolTokens: number;
|
|
24
|
+
}
|
|
25
|
+
export declare class ContextAnalyzer {
|
|
26
|
+
/**
|
|
27
|
+
* Heuristic for token estimation: approx 4 chars per token.
|
|
28
|
+
*/
|
|
29
|
+
private static TOKEN_HEURISTIC;
|
|
30
|
+
/**
|
|
31
|
+
* Analyze session messages to produce a context map.
|
|
32
|
+
* @param messages The full session conversation history.
|
|
33
|
+
* @param currentTurn The current turn number.
|
|
34
|
+
*/
|
|
35
|
+
analyze(messages: any[], currentTurn: number): ContextMap;
|
|
36
|
+
private extractPath;
|
|
37
|
+
private getOpType;
|
|
38
|
+
private calculateStatus;
|
|
39
|
+
private findToolResult;
|
|
40
|
+
}
|
package/dist/analyzer.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* ContextAnalyzer
|
|
4
|
+
* Responsible for parsing Pi session messages to identify the active working set of files,
|
|
5
|
+
* their token weights, and their temporal status.
|
|
6
|
+
*/
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.ContextAnalyzer = void 0;
|
|
9
|
+
class ContextAnalyzer {
|
|
10
|
+
/**
|
|
11
|
+
* Heuristic for token estimation: approx 4 chars per token.
|
|
12
|
+
*/
|
|
13
|
+
static TOKEN_HEURISTIC = 4;
|
|
14
|
+
/**
|
|
15
|
+
* Analyze session messages to produce a context map.
|
|
16
|
+
* @param messages The full session conversation history.
|
|
17
|
+
* @param currentTurn The current turn number.
|
|
18
|
+
*/
|
|
19
|
+
analyze(messages, currentTurn) {
|
|
20
|
+
const fileRegistry = new Map();
|
|
21
|
+
let totalTokens = 0;
|
|
22
|
+
let fileTokens = 0;
|
|
23
|
+
let toolTokens = 0;
|
|
24
|
+
messages.forEach((msg, index) => {
|
|
25
|
+
const turn = index + 1;
|
|
26
|
+
// Basic token estimation for the message
|
|
27
|
+
const msgText = typeof msg.content === "string"
|
|
28
|
+
? msg.content
|
|
29
|
+
: JSON.stringify(msg.content);
|
|
30
|
+
const msgTokens = Math.ceil(msgText.length / ContextAnalyzer.TOKEN_HEURISTIC);
|
|
31
|
+
totalTokens += msgTokens;
|
|
32
|
+
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
|
33
|
+
for (const block of msg.content) {
|
|
34
|
+
if (block.type === "tool_use") {
|
|
35
|
+
const input = block.input;
|
|
36
|
+
const path = this.extractPath(block.name, input);
|
|
37
|
+
if (path) {
|
|
38
|
+
const opType = this.getOpType(block.name);
|
|
39
|
+
// If the file is already tracked, update it
|
|
40
|
+
// Find the tool result for this tool use to get actual content length
|
|
41
|
+
const result = this.findToolResult(messages, index, block.id);
|
|
42
|
+
const content = result?.content || "";
|
|
43
|
+
const weight = Math.ceil(String(content).length / ContextAnalyzer.TOKEN_HEURISTIC);
|
|
44
|
+
fileRegistry.set(path, {
|
|
45
|
+
path,
|
|
46
|
+
weight,
|
|
47
|
+
lastOp: {
|
|
48
|
+
type: opType,
|
|
49
|
+
turn,
|
|
50
|
+
timestamp: msg.timestamp || Date.now(),
|
|
51
|
+
},
|
|
52
|
+
status: this.calculateStatus(turn, currentTurn),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (msg.role === "tool") {
|
|
59
|
+
toolTokens += Math.ceil(String(msg.content).length / ContextAnalyzer.TOKEN_HEURISTIC);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
const files = Array.from(fileRegistry.values());
|
|
63
|
+
fileTokens = files.reduce((acc, f) => acc + f.weight, 0);
|
|
64
|
+
return {
|
|
65
|
+
files: files.sort((a, b) => b.weight - a.weight).slice(0, 100),
|
|
66
|
+
totalTokens,
|
|
67
|
+
systemTokens: 0, // Pi provides this via ctx, not messages
|
|
68
|
+
historyTokens: totalTokens - fileTokens - toolTokens,
|
|
69
|
+
fileTokens,
|
|
70
|
+
toolTokens,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
extractPath(toolName, input) {
|
|
74
|
+
if (toolName === "read" || toolName === "write" || toolName === "edit") {
|
|
75
|
+
return typeof input.path === "string" ? input.path : null;
|
|
76
|
+
}
|
|
77
|
+
if (toolName === "bash") {
|
|
78
|
+
// Simple regex for paths in bash commands (e.g., cat path/to/file)
|
|
79
|
+
const match = input.command?.match(/(?:cat|ls|rm|mv|cp|vi|nano)\s+([^\s;]+)/);
|
|
80
|
+
return match ? match[1] : null;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
getOpType(toolName) {
|
|
85
|
+
switch (toolName) {
|
|
86
|
+
case "write":
|
|
87
|
+
return "write";
|
|
88
|
+
case "edit":
|
|
89
|
+
return "edit";
|
|
90
|
+
case "bash":
|
|
91
|
+
return "delete"; // Simplified; usually bash implies modification or deletion
|
|
92
|
+
default:
|
|
93
|
+
return "read";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
calculateStatus(turn, currentTurn) {
|
|
97
|
+
const diff = currentTurn - turn;
|
|
98
|
+
if (diff <= 3)
|
|
99
|
+
return "active";
|
|
100
|
+
if (diff <= 10)
|
|
101
|
+
return "stale";
|
|
102
|
+
return "legacy";
|
|
103
|
+
}
|
|
104
|
+
findToolResult(messages, toolTurnIndex, toolId) {
|
|
105
|
+
// Look for the tool result immediately following the tool use
|
|
106
|
+
for (let i = toolTurnIndex + 1; i < messages.length; i++) {
|
|
107
|
+
if (messages[i].role === "tool" && messages[i].tool_call_id === toolId) {
|
|
108
|
+
return messages[i];
|
|
109
|
+
}
|
|
110
|
+
// If we hit another assistant turn, the result for this specific call is likely gone/compacted
|
|
111
|
+
if (messages[i].role === "assistant")
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
exports.ContextAnalyzer = ContextAnalyzer;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ReportGenerator
|
|
3
|
+
* Generates a visual HTML dashboard based on the ContextMap.
|
|
4
|
+
*/
|
|
5
|
+
import type { ContextMap } from "./analyzer";
|
|
6
|
+
export declare class ReportGenerator {
|
|
7
|
+
static generateHTML(map: ContextMap): string;
|
|
8
|
+
static writeReport(html: string): string;
|
|
9
|
+
private static getOpIcon;
|
|
10
|
+
private static escapeHtml;
|
|
11
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* ReportGenerator
|
|
4
|
+
* Generates a visual HTML dashboard based on the ContextMap.
|
|
5
|
+
*/
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.ReportGenerator = void 0;
|
|
8
|
+
const node_fs_1 = require("node:fs");
|
|
9
|
+
const node_path_1 = require("node:path");
|
|
10
|
+
const node_os_1 = require("node:os");
|
|
11
|
+
class ReportGenerator {
|
|
12
|
+
static generateHTML(map) {
|
|
13
|
+
const fileCards = map.files
|
|
14
|
+
.map((file) => `
|
|
15
|
+
<div class="file-card ${file.status}">
|
|
16
|
+
<div class="file-header">
|
|
17
|
+
<span class="file-path">${ReportGenerator.escapeHtml(file.path)}</span>
|
|
18
|
+
<span class="file-weight">${file.weight.toLocaleString()} tokens</span>
|
|
19
|
+
</div>
|
|
20
|
+
<div class="file-footer">
|
|
21
|
+
<span class="op-badge">${ReportGenerator.getOpIcon(file.lastOp.type)} ${file.lastOp.type}</span>
|
|
22
|
+
<span class="turn-badge">Turn ${file.lastOp.turn}</span>
|
|
23
|
+
<span class="status-text">${file.status.toUpperCase()}</span>
|
|
24
|
+
</div>
|
|
25
|
+
<div class="weight-bar">
|
|
26
|
+
<div class="weight-fill" style="width: ${Math.min(100, (file.weight / 1000) * 100)}%"></div>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
`)
|
|
30
|
+
.join("");
|
|
31
|
+
const budgetPercent = (map.fileTokens / map.totalTokens) * 100;
|
|
32
|
+
return `
|
|
33
|
+
<!DOCTYPE html>
|
|
34
|
+
<html lang="en">
|
|
35
|
+
<head>
|
|
36
|
+
<meta charset="UTF-8">
|
|
37
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
38
|
+
<title>Pi Context Map</title>
|
|
39
|
+
<style>
|
|
40
|
+
:root {
|
|
41
|
+
--bg: #0f172a;
|
|
42
|
+
--card-bg: #1e293b;
|
|
43
|
+
--text: #f1f5f9;
|
|
44
|
+
--text-dim: #94a3b8;
|
|
45
|
+
--primary: #38bdf8;
|
|
46
|
+
--active: #22c55e;
|
|
47
|
+
--stale: #eab308;
|
|
48
|
+
--legacy: #ef4444;
|
|
49
|
+
--border: #334155;
|
|
50
|
+
}
|
|
51
|
+
body {
|
|
52
|
+
background: var(--bg);
|
|
53
|
+
color: var(--text);
|
|
54
|
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
55
|
+
margin: 0;
|
|
56
|
+
padding: 2rem;
|
|
57
|
+
line-height: 1.5;
|
|
58
|
+
}
|
|
59
|
+
.container { max-width: 1200px; margin: 0 auto; }
|
|
60
|
+
header { margin-bottom: 3rem; border-bottom: 1px solid var(--border); padding-bottom: 2rem; }
|
|
61
|
+
h1 { font-size: 2rem; margin: 0; color: var(--primary); }
|
|
62
|
+
.stats-grid {
|
|
63
|
+
display: grid;
|
|
64
|
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
65
|
+
gap: 1.5rem;
|
|
66
|
+
margin-top: 2rem;
|
|
67
|
+
}
|
|
68
|
+
.stat-card {
|
|
69
|
+
background: var(--card-bg);
|
|
70
|
+
padding: 1.5rem;
|
|
71
|
+
border-radius: 12px;
|
|
72
|
+
border: 1px solid var(--border);
|
|
73
|
+
text-align: center;
|
|
74
|
+
}
|
|
75
|
+
.stat-value { font-size: 1.5rem; font-weight: bold; display: block; }
|
|
76
|
+
.stat-label { color: var(--text-dim); font-size: 0.875rem; text-transform: uppercase; }
|
|
77
|
+
|
|
78
|
+
.budget-container {
|
|
79
|
+
margin: 2rem 0;
|
|
80
|
+
background: var(--card-bg);
|
|
81
|
+
padding: 1rem;
|
|
82
|
+
border-radius: 12px;
|
|
83
|
+
border: 1px solid var(--border);
|
|
84
|
+
}
|
|
85
|
+
.budget-bar {
|
|
86
|
+
height: 24px;
|
|
87
|
+
background: #020617;
|
|
88
|
+
border-radius: 12px;
|
|
89
|
+
display: flex;
|
|
90
|
+
overflow: hidden;
|
|
91
|
+
margin-bottom: 0.5rem;
|
|
92
|
+
}
|
|
93
|
+
.budget-segment { height: 100%; transition: width 0.3s ease; }
|
|
94
|
+
.seg-system { background: #6366f1; }
|
|
95
|
+
.seg-history { background: #a855f7; }
|
|
96
|
+
.seg-files { background: var(--primary); }
|
|
97
|
+
.seg-tools { background: #ec4899; }
|
|
98
|
+
|
|
99
|
+
.budget-legend {
|
|
100
|
+
display: flex;
|
|
101
|
+
gap: 1rem;
|
|
102
|
+
justify-content: center;
|
|
103
|
+
font-size: 0.75rem;
|
|
104
|
+
color: var(--text-dim);
|
|
105
|
+
}
|
|
106
|
+
.legend-item { display: flex; align-items: center; gap: 0.5rem; }
|
|
107
|
+
.dot { width: 8px; height: 8px; border-radius: 50%; }
|
|
108
|
+
|
|
109
|
+
.file-grid {
|
|
110
|
+
display: grid;
|
|
111
|
+
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
|
112
|
+
gap: 1rem;
|
|
113
|
+
}
|
|
114
|
+
.file-card {
|
|
115
|
+
background: var(--card-bg);
|
|
116
|
+
border: 1px solid var(--border);
|
|
117
|
+
border-radius: 12px;
|
|
118
|
+
padding: 1rem;
|
|
119
|
+
transition: transform 0.2s ease, border-color 0.2s ease;
|
|
120
|
+
display: flex;
|
|
121
|
+
flex-direction: column;
|
|
122
|
+
justify-content: space-between;
|
|
123
|
+
}
|
|
124
|
+
.file-card:hover { transform: translateY(-4px); border-color: var(--primary); }
|
|
125
|
+
.file-header {
|
|
126
|
+
display: flex;
|
|
127
|
+
justify-content: space-between;
|
|
128
|
+
align-items: flex-start;
|
|
129
|
+
margin-bottom: 1rem;
|
|
130
|
+
}
|
|
131
|
+
.file-path {
|
|
132
|
+
font-family: 'Fira Code', monospace;
|
|
133
|
+
font-size: 0.875rem;
|
|
134
|
+
word-break: break-all;
|
|
135
|
+
margin-right: 1rem;
|
|
136
|
+
color: var(--text);
|
|
137
|
+
}
|
|
138
|
+
.file-weight { font-size: 0.75rem; color: var(--text-dim); white-space: nowrap; }
|
|
139
|
+
.file-footer {
|
|
140
|
+
display: flex;
|
|
141
|
+
justify-content: space-between;
|
|
142
|
+
align-items: center;
|
|
143
|
+
margin-top: 1rem;
|
|
144
|
+
font-size: 0.75rem;
|
|
145
|
+
}
|
|
146
|
+
.op-badge {
|
|
147
|
+
background: #0f172a;
|
|
148
|
+
padding: 2px 6px;
|
|
149
|
+
border-radius: 4px;
|
|
150
|
+
color: var(--text-dim);
|
|
151
|
+
}
|
|
152
|
+
.turn-badge { color: var(--text-dim); }
|
|
153
|
+
.status-text { font-weight: bold; text-transform: uppercase; }
|
|
154
|
+
|
|
155
|
+
/* Status Colors */
|
|
156
|
+
.active { border-left: 4px solid var(--active); }
|
|
157
|
+
.active .status-text { color: var(--active); }
|
|
158
|
+
.stale { border-left: 4px solid var(--stale); }
|
|
159
|
+
.stale .status-text { color: var(--stale); }
|
|
160
|
+
.legacy { border-left: 4px solid var(--legacy); }
|
|
161
|
+
.legacy .status-text { color: var(--legacy); }
|
|
162
|
+
|
|
163
|
+
.weight-bar {
|
|
164
|
+
height: 4px;
|
|
165
|
+
background: #020617;
|
|
166
|
+
border-radius: 2px;
|
|
167
|
+
margin-top: 1rem;
|
|
168
|
+
overflow: hidden;
|
|
169
|
+
}
|
|
170
|
+
.weight-fill {
|
|
171
|
+
height: 100%;
|
|
172
|
+
background: var(--primary);
|
|
173
|
+
transition: width 0.3s ease;
|
|
174
|
+
}
|
|
175
|
+
</style>
|
|
176
|
+
</head>
|
|
177
|
+
<body>
|
|
178
|
+
<div class="container">
|
|
179
|
+
<header>
|
|
180
|
+
<h1>Pi Context Map</h1>
|
|
181
|
+
<p style="color: var(--text-dim)">Session context window visualization and token distribution.</p>
|
|
182
|
+
|
|
183
|
+
<div class="stats-grid">
|
|
184
|
+
<div class="stat-card">
|
|
185
|
+
<span class="stat-value">${map.totalTokens.toLocaleString()}</span>
|
|
186
|
+
<span class="stat-label">Total Tokens</span>
|
|
187
|
+
</div>
|
|
188
|
+
<div class="stat-card">
|
|
189
|
+
<span class="stat-value">${map.files.length}</span>
|
|
190
|
+
<span class="stat-label">Files in Context</span>
|
|
191
|
+
</div>
|
|
192
|
+
<div class="stat-card">
|
|
193
|
+
<span class="stat-value">${map.fileTokens.toLocaleString()}</span>
|
|
194
|
+
<span class="stat-label">File Tokens</span>
|
|
195
|
+
</div>
|
|
196
|
+
<div class="stat-card">
|
|
197
|
+
<span class="stat-value">${Math.round(budgetPercent)}%</span>
|
|
198
|
+
<span class="stat-label">File Load</span>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
<div class="budget-container">
|
|
203
|
+
<div class="budget-bar">
|
|
204
|
+
<div class="budget-segment seg-system" style="width: ${(map.systemTokens / map.totalTokens) * 100 || 0}%"></div>
|
|
205
|
+
<div class="budget-segment seg-history" style="width: ${(map.historyTokens / map.totalTokens) * 100 || 0}%"></div>
|
|
206
|
+
<div class="budget-segment seg-files" style="width: ${(map.fileTokens / map.totalTokens) * 100 || 0}%"></div>
|
|
207
|
+
<div class="budget-segment seg-tools" style="width: ${(map.toolTokens / map.totalTokens) * 100 || 0}%"></div>
|
|
208
|
+
</div>
|
|
209
|
+
<div class="budget-legend">
|
|
210
|
+
<div class="legend-item"><span class="dot seg-system"></span> System</div>
|
|
211
|
+
<div class="legend-item"><span class="dot seg-history"></span> History</div>
|
|
212
|
+
<div class="legend-item"><span class="dot seg-files"></span> Files</div>
|
|
213
|
+
<div class="legend-item"><span class="dot seg-tools"></span> Tools</div>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
</header>
|
|
217
|
+
|
|
218
|
+
<div class="file-grid">
|
|
219
|
+
${fileCards}
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
</body>
|
|
223
|
+
</html>
|
|
224
|
+
`;
|
|
225
|
+
}
|
|
226
|
+
static writeReport(html) {
|
|
227
|
+
const reportDir = (0, node_path_1.join)((0, node_os_1.homedir)(), ".pi", "context-map");
|
|
228
|
+
(0, node_fs_1.mkdirSync)(reportDir, { recursive: true });
|
|
229
|
+
const reportPath = (0, node_path_1.join)(reportDir, "report.html");
|
|
230
|
+
(0, node_fs_1.writeFileSync)(reportPath, html, "utf8");
|
|
231
|
+
return reportPath;
|
|
232
|
+
}
|
|
233
|
+
static getOpIcon(type) {
|
|
234
|
+
switch (type) {
|
|
235
|
+
case "read":
|
|
236
|
+
return "👁️";
|
|
237
|
+
case "write":
|
|
238
|
+
return "📝";
|
|
239
|
+
case "edit":
|
|
240
|
+
return "✍️";
|
|
241
|
+
case "delete":
|
|
242
|
+
return "🗑️";
|
|
243
|
+
default:
|
|
244
|
+
return "📄";
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
static escapeHtml(text) {
|
|
248
|
+
const map = {
|
|
249
|
+
"&": "&",
|
|
250
|
+
"<": "<",
|
|
251
|
+
">": ">",
|
|
252
|
+
'"': """,
|
|
253
|
+
"'": "'",
|
|
254
|
+
};
|
|
255
|
+
return text.replace(/[&<>"']/g, (m) => map[m]);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
exports.ReportGenerator = ReportGenerator;
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* pi-context-map
|
|
4
|
+
* Pi extension to visualize session context window and token distribution.
|
|
5
|
+
*/
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.default = default_1;
|
|
8
|
+
const analyzer_1 = require("./analyzer");
|
|
9
|
+
const generator_1 = require("./generator");
|
|
10
|
+
function default_1(pi) {
|
|
11
|
+
const analyzer = new analyzer_1.ContextAnalyzer();
|
|
12
|
+
// Register the /context-map command
|
|
13
|
+
pi.registerCommand("context-map", {
|
|
14
|
+
description: "Generate a visual map of the current session context window.",
|
|
15
|
+
handler: (_args, ctx) => {
|
|
16
|
+
ctx.ui.notify("Analyzing session context...", "info");
|
|
17
|
+
try {
|
|
18
|
+
// 1. Extract messages and current turn
|
|
19
|
+
// Note: We assume ctx.session.messages is available.
|
|
20
|
+
// If not, we may need to fetch them via another API or use provided event data.
|
|
21
|
+
const messages = ctx.session.messages || [];
|
|
22
|
+
const currentTurn = messages.length;
|
|
23
|
+
if (messages.length === 0) {
|
|
24
|
+
ctx.ui.notify("No session history found to map.", "warning");
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
// 2. Analyze context
|
|
28
|
+
const map = analyzer.analyze(messages, currentTurn);
|
|
29
|
+
// 3. Generate HTML Report
|
|
30
|
+
const html = generator_1.ReportGenerator.generateHTML(map);
|
|
31
|
+
const reportPath = generator_1.ReportGenerator.writeReport(html);
|
|
32
|
+
ctx.ui.notify(`Context map generated successfully! \nPath: ${reportPath}`, "success");
|
|
33
|
+
// Providing a link or instruction to open the report
|
|
34
|
+
ctx.ui.notify("You can open the report.html in your browser to see the visualization.", "info");
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
38
|
+
ctx.ui.notify(`Failed to generate context map: ${message}`, "error");
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
// Optional: Notify the user when a significant amount of context is loaded
|
|
43
|
+
pi.on("session_before_compact", (event, ctx) => {
|
|
44
|
+
const { preparation } = event;
|
|
45
|
+
const tokens = preparation.tokensBefore;
|
|
46
|
+
if (tokens > 100_000) {
|
|
47
|
+
ctx.ui.notify(`High context load detected (${(tokens / 1000).toFixed(1)}k tokens). Try /context-map to see what's consuming space.`, "info");
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
package/docs/proposal.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Proposal: pi-context-map
|
|
2
|
+
|
|
3
|
+
## 1. Problem Statement
|
|
4
|
+
As Pi sessions grow in complexity, the "Context Window" becomes a black box. Users and agents often lose track of:
|
|
5
|
+
- Which files are currently consuming the most tokens.
|
|
6
|
+
- When a file was last "refreshed" (read) by the agent.
|
|
7
|
+
- The distribution of tokens between history, system prompts, and tool outputs.
|
|
8
|
+
|
|
9
|
+
This leads to "context bloat," where the agent becomes sluggish or forgets critical instructions because the window is filled with stale file content.
|
|
10
|
+
|
|
11
|
+
## 2. Goal
|
|
12
|
+
Create a Pi extension that provides a **real-time, visual map of the current session's context**. It should transform the abstract concept of a "token window" into a concrete, actionable dashboard.
|
|
13
|
+
|
|
14
|
+
## 3. Core Features
|
|
15
|
+
### A. `/context-map` Command
|
|
16
|
+
A command that generates a visual report of the current context.
|
|
17
|
+
|
|
18
|
+
### B. Context Analysis
|
|
19
|
+
- **File Inventory**: List all files currently in context.
|
|
20
|
+
- **Weight Tracking**: Approximate token count per file.
|
|
21
|
+
- **Status Mapping**:
|
|
22
|
+
- `Active`: Read/Modified in the last 3 turns.
|
|
23
|
+
- `Stale`: Read 4-10 turns ago.
|
|
24
|
+
- `Legacy`: Read >10 turns ago (candidate for compaction).
|
|
25
|
+
- **Operation History**: Mark if a file was Read 🟢, Written 🟠, or Edited 🟡.
|
|
26
|
+
|
|
27
|
+
### C. Visual Output
|
|
28
|
+
The extension will generate an `index.html` report (stored in `.pi/context-map/report.html`) featuring:
|
|
29
|
+
- **Token Budget Bar**: Visual breakdown of context usage.
|
|
30
|
+
- **File Treemap/List**: Files sized by their token weight.
|
|
31
|
+
- **Temporal Timeline**: A simple timeline showing when files entered the context.
|
|
32
|
+
|
|
33
|
+
## 4. Success Criteria
|
|
34
|
+
- The user can run `/context-map` and immediately see which file is the "token hog."
|
|
35
|
+
- The user can identify "stale" files that can be removed to free up space.
|
|
36
|
+
- Zero performance degradation during normal session operation.
|
|
37
|
+
- Full compatibility with `pi-ultra-compact` (since both manage context).
|
|
38
|
+
|
|
39
|
+
## 5. Non-Goals
|
|
40
|
+
- This is a *visualization* tool, not an *automatic* context cleaner (though it provides the data needed for a user to trigger compaction).
|
|
41
|
+
- It will not modify the actual LLM context window, only report on it.
|
package/docs/spec.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Technical Specification: pi-context-map
|
|
2
|
+
|
|
3
|
+
## 1. Architecture Overview
|
|
4
|
+
`pi-context-map` is a Pi extension that analyzes the session message history to derive a "map" of the active context. It operates as a read-only analysis tool triggered by a user command.
|
|
5
|
+
|
|
6
|
+
## 2. Data Extraction Logic
|
|
7
|
+
When `/context-map` is called, the extension will:
|
|
8
|
+
1. **Scan Messages**: Iterate through all messages in the current session history.
|
|
9
|
+
2. **Identify File Ops**:
|
|
10
|
+
- Scan `tool_use` blocks for `read`, `write`, `edit`, and `bash` (regex for file paths).
|
|
11
|
+
- Map each file to its most recent occurrence (turn number).
|
|
12
|
+
3. **Calculate Weights**:
|
|
13
|
+
- For each file found, retrieve its content length from the corresponding `tool_result` if available.
|
|
14
|
+
- Estimate tokens using a heuristic: $\text{tokens} \approx \text{chars} / 4$.
|
|
15
|
+
4. **Assign Status**:
|
|
16
|
+
- **Active**: Turn difference $\le 3$.
|
|
17
|
+
- **Stale**: $3 <$ Turn difference $\le 10$.
|
|
18
|
+
- **Legacy**: Turn difference $> 10$.
|
|
19
|
+
|
|
20
|
+
## 3. Component Design
|
|
21
|
+
|
|
22
|
+
### A. `ContextAnalyzer` (Class)
|
|
23
|
+
- `analyze(messages: Message[])`: Returns a `ContextMap` object.
|
|
24
|
+
- `calculateTokens(text: string)`: Returns estimated token count.
|
|
25
|
+
- `getFileMetadata(path: string)`: Tracks the operation type and timestamp.
|
|
26
|
+
|
|
27
|
+
### B. `ReportGenerator` (Class)
|
|
28
|
+
- `generateHTML(map: ContextMap)`: Produces a standalone HTML string.
|
|
29
|
+
- `writeReport(html: string)`: Saves the report to `.pi/context-map/report.html` and opens it.
|
|
30
|
+
|
|
31
|
+
### C. `ExtensionEntry` (Main)
|
|
32
|
+
- `pi.registerCommand("context-map", ...)`: The entry point.
|
|
33
|
+
- `pi.on("session_before_compact", ...)`: (Optional) Could trigger a map update before compaction.
|
|
34
|
+
|
|
35
|
+
## 4. Visual Specification (HTML Dashboard)
|
|
36
|
+
The report will be a single-file HTML dashboard with:
|
|
37
|
+
- **Header**: Session ID, Total Estimated Tokens, and Timestamp.
|
|
38
|
+
- **Context Budget**: A CSS-based stacked bar showing:
|
|
39
|
+
- `[ System ] [ History ] [ Files ] [ Tool Outputs ]`
|
|
40
|
+
- **File Grid**:
|
|
41
|
+
- Cards for each file.
|
|
42
|
+
- Size proportional to token weight.
|
|
43
|
+
- Color-coded by status (Green $\to$ Yellow $\to$ Red).
|
|
44
|
+
- Icons for operation type (👁️ for read, ✍️ for edit).
|
|
45
|
+
- **Stats Table**:
|
|
46
|
+
- File Path | Tokens | Last Turn | Status.
|
|
47
|
+
|
|
48
|
+
## 5. Implementation Details
|
|
49
|
+
- **Language**: TypeScript.
|
|
50
|
+
- **Dependencies**: `@earendil-works/pi-coding-agent`, `node:fs`, `node:path`.
|
|
51
|
+
- **Complexity**:
|
|
52
|
+
- Time: $O(N)$ where $N$ is number of messages.
|
|
53
|
+
- Space: $O(F)$ where $F$ is number of unique files in context.
|
|
54
|
+
|
|
55
|
+
## 6. Error Handling
|
|
56
|
+
- **Missing Tool Results**: If a file was read but the result is missing (e.g., due to previous compaction), mark weight as "Unknown" and status as "Stale".
|
|
57
|
+
- **Large Repos**: Limit the map to the top 100 largest files to prevent the HTML report from crashing the browser.
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-context-map",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A Pi extension that visualizes the current session context window and token distribution.",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"dev": "tsc -w"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"pi",
|
|
13
|
+
"extension",
|
|
14
|
+
"context",
|
|
15
|
+
"visualization",
|
|
16
|
+
"tokens"
|
|
17
|
+
],
|
|
18
|
+
"author": "ZachDreamZ",
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"pi": {
|
|
21
|
+
"extensions": [
|
|
22
|
+
{
|
|
23
|
+
"name": "context-map",
|
|
24
|
+
"entry": "dist/index.js",
|
|
25
|
+
"description": "Visualizes the session context window and token distribution."
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"typescript": "^5.0.0",
|
|
32
|
+
"@types/node": "^20.0.0"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/analyzer.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ContextAnalyzer
|
|
3
|
+
* Responsible for parsing Pi session messages to identify the active working set of files,
|
|
4
|
+
* their token weights, and their temporal status.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface FileOp {
|
|
8
|
+
type: "read" | "write" | "edit" | "delete";
|
|
9
|
+
turn: number;
|
|
10
|
+
timestamp: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface FileContext {
|
|
14
|
+
path: string;
|
|
15
|
+
weight: number; // Estimated tokens
|
|
16
|
+
lastOp: FileOp;
|
|
17
|
+
status: "active" | "stale" | "legacy";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ContextMap {
|
|
21
|
+
files: FileContext[];
|
|
22
|
+
totalTokens: number;
|
|
23
|
+
systemTokens: number;
|
|
24
|
+
historyTokens: number;
|
|
25
|
+
fileTokens: number;
|
|
26
|
+
toolTokens: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class ContextAnalyzer {
|
|
30
|
+
/**
|
|
31
|
+
* Heuristic for token estimation: approx 4 chars per token.
|
|
32
|
+
*/
|
|
33
|
+
private static TOKEN_HEURISTIC = 4;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Analyze session messages to produce a context map.
|
|
37
|
+
* @param messages The full session conversation history.
|
|
38
|
+
* @param currentTurn The current turn number.
|
|
39
|
+
*/
|
|
40
|
+
public analyze(messages: any[], currentTurn: number): ContextMap {
|
|
41
|
+
const fileRegistry = new Map<string, FileContext>();
|
|
42
|
+
let totalTokens = 0;
|
|
43
|
+
let fileTokens = 0;
|
|
44
|
+
let toolTokens = 0;
|
|
45
|
+
|
|
46
|
+
messages.forEach((msg, index) => {
|
|
47
|
+
const turn = index + 1;
|
|
48
|
+
|
|
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;
|
|
58
|
+
|
|
59
|
+
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
|
60
|
+
for (const block of msg.content) {
|
|
61
|
+
if (block.type === "tool_use") {
|
|
62
|
+
const input = block.input as Record<string, any>;
|
|
63
|
+
const path = this.extractPath(block.name, input);
|
|
64
|
+
|
|
65
|
+
if (path) {
|
|
66
|
+
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
|
+
const result = this.findToolResult(messages, index, block.id);
|
|
72
|
+
const content = result?.content || "";
|
|
73
|
+
const weight = Math.ceil(
|
|
74
|
+
String(content).length / ContextAnalyzer.TOKEN_HEURISTIC,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
fileRegistry.set(path, {
|
|
78
|
+
path,
|
|
79
|
+
weight,
|
|
80
|
+
lastOp: {
|
|
81
|
+
type: opType,
|
|
82
|
+
turn,
|
|
83
|
+
timestamp: msg.timestamp || Date.now(),
|
|
84
|
+
},
|
|
85
|
+
status: this.calculateStatus(turn, currentTurn),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (msg.role === "tool") {
|
|
93
|
+
toolTokens += Math.ceil(
|
|
94
|
+
String(msg.content).length / ContextAnalyzer.TOKEN_HEURISTIC,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const files = Array.from(fileRegistry.values());
|
|
100
|
+
fileTokens = files.reduce((acc, f) => acc + f.weight, 0);
|
|
101
|
+
|
|
102
|
+
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,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private extractPath(toolName: string, input: any): string | null {
|
|
113
|
+
if (toolName === "read" || toolName === "write" || toolName === "edit") {
|
|
114
|
+
return typeof input.path === "string" ? input.path : null;
|
|
115
|
+
}
|
|
116
|
+
if (toolName === "bash") {
|
|
117
|
+
// Simple regex for paths in bash commands (e.g., cat path/to/file)
|
|
118
|
+
const match = input.command?.match(
|
|
119
|
+
/(?:cat|ls|rm|mv|cp|vi|nano)\s+([^\s;]+)/,
|
|
120
|
+
);
|
|
121
|
+
return match ? match[1] : null;
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private getOpType(toolName: string): FileOp["type"] {
|
|
127
|
+
switch (toolName) {
|
|
128
|
+
case "write":
|
|
129
|
+
return "write";
|
|
130
|
+
case "edit":
|
|
131
|
+
return "edit";
|
|
132
|
+
case "bash":
|
|
133
|
+
return "delete"; // Simplified; usually bash implies modification or deletion
|
|
134
|
+
default:
|
|
135
|
+
return "read";
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private calculateStatus(
|
|
140
|
+
turn: number,
|
|
141
|
+
currentTurn: number,
|
|
142
|
+
): FileContext["status"] {
|
|
143
|
+
const diff = currentTurn - turn;
|
|
144
|
+
if (diff <= 3) return "active";
|
|
145
|
+
if (diff <= 10) return "stale";
|
|
146
|
+
return "legacy";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private findToolResult(
|
|
150
|
+
messages: any[],
|
|
151
|
+
toolTurnIndex: number,
|
|
152
|
+
toolId: string,
|
|
153
|
+
): any {
|
|
154
|
+
// Look for the tool result immediately following the tool use
|
|
155
|
+
for (let i = toolTurnIndex + 1; i < messages.length; i++) {
|
|
156
|
+
if (messages[i].role === "tool" && messages[i].tool_call_id === toolId) {
|
|
157
|
+
return messages[i];
|
|
158
|
+
}
|
|
159
|
+
// If we hit another assistant turn, the result for this specific call is likely gone/compacted
|
|
160
|
+
if (messages[i].role === "assistant") break;
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
package/src/generator.ts
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ReportGenerator
|
|
3
|
+
* Generates a visual HTML dashboard based on the ContextMap.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ContextMap } from "./analyzer";
|
|
7
|
+
import { writeFileSync, mkdirSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
|
|
11
|
+
export class ReportGenerator {
|
|
12
|
+
public static generateHTML(map: ContextMap): string {
|
|
13
|
+
const fileCards = map.files
|
|
14
|
+
.map(
|
|
15
|
+
(file) => `
|
|
16
|
+
<div class="file-card ${file.status}">
|
|
17
|
+
<div class="file-header">
|
|
18
|
+
<span class="file-path">${ReportGenerator.escapeHtml(file.path)}</span>
|
|
19
|
+
<span class="file-weight">${file.weight.toLocaleString()} tokens</span>
|
|
20
|
+
</div>
|
|
21
|
+
<div class="file-footer">
|
|
22
|
+
<span class="op-badge">${ReportGenerator.getOpIcon(file.lastOp.type)} ${file.lastOp.type}</span>
|
|
23
|
+
<span class="turn-badge">Turn ${file.lastOp.turn}</span>
|
|
24
|
+
<span class="status-text">${file.status.toUpperCase()}</span>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="weight-bar">
|
|
27
|
+
<div class="weight-fill" style="width: ${Math.min(100, (file.weight / 1000) * 100)}%"></div>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
`,
|
|
31
|
+
)
|
|
32
|
+
.join("");
|
|
33
|
+
|
|
34
|
+
const budgetPercent = (map.fileTokens / map.totalTokens) * 100;
|
|
35
|
+
|
|
36
|
+
return `
|
|
37
|
+
<!DOCTYPE html>
|
|
38
|
+
<html lang="en">
|
|
39
|
+
<head>
|
|
40
|
+
<meta charset="UTF-8">
|
|
41
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
42
|
+
<title>Pi Context Map</title>
|
|
43
|
+
<style>
|
|
44
|
+
: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;
|
|
54
|
+
}
|
|
55
|
+
body {
|
|
56
|
+
background: var(--bg);
|
|
57
|
+
color: var(--text);
|
|
58
|
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
59
|
+
margin: 0;
|
|
60
|
+
padding: 2rem;
|
|
61
|
+
line-height: 1.5;
|
|
62
|
+
}
|
|
63
|
+
.container { max-width: 1200px; margin: 0 auto; }
|
|
64
|
+
header { margin-bottom: 3rem; border-bottom: 1px solid var(--border); padding-bottom: 2rem; }
|
|
65
|
+
h1 { font-size: 2rem; margin: 0; color: var(--primary); }
|
|
66
|
+
.stats-grid {
|
|
67
|
+
display: grid;
|
|
68
|
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
69
|
+
gap: 1.5rem;
|
|
70
|
+
margin-top: 2rem;
|
|
71
|
+
}
|
|
72
|
+
.stat-card {
|
|
73
|
+
background: var(--card-bg);
|
|
74
|
+
padding: 1.5rem;
|
|
75
|
+
border-radius: 12px;
|
|
76
|
+
border: 1px solid var(--border);
|
|
77
|
+
text-align: center;
|
|
78
|
+
}
|
|
79
|
+
.stat-value { font-size: 1.5rem; font-weight: bold; display: block; }
|
|
80
|
+
.stat-label { color: var(--text-dim); font-size: 0.875rem; text-transform: uppercase; }
|
|
81
|
+
|
|
82
|
+
.budget-container {
|
|
83
|
+
margin: 2rem 0;
|
|
84
|
+
background: var(--card-bg);
|
|
85
|
+
padding: 1rem;
|
|
86
|
+
border-radius: 12px;
|
|
87
|
+
border: 1px solid var(--border);
|
|
88
|
+
}
|
|
89
|
+
.budget-bar {
|
|
90
|
+
height: 24px;
|
|
91
|
+
background: #020617;
|
|
92
|
+
border-radius: 12px;
|
|
93
|
+
display: flex;
|
|
94
|
+
overflow: hidden;
|
|
95
|
+
margin-bottom: 0.5rem;
|
|
96
|
+
}
|
|
97
|
+
.budget-segment { height: 100%; transition: width 0.3s ease; }
|
|
98
|
+
.seg-system { background: #6366f1; }
|
|
99
|
+
.seg-history { background: #a855f7; }
|
|
100
|
+
.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;
|
|
108
|
+
color: var(--text-dim);
|
|
109
|
+
}
|
|
110
|
+
.legend-item { display: flex; align-items: center; gap: 0.5rem; }
|
|
111
|
+
.dot { width: 8px; height: 8px; border-radius: 50%; }
|
|
112
|
+
|
|
113
|
+
.file-grid {
|
|
114
|
+
display: grid;
|
|
115
|
+
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
|
116
|
+
gap: 1rem;
|
|
117
|
+
}
|
|
118
|
+
.file-card {
|
|
119
|
+
background: var(--card-bg);
|
|
120
|
+
border: 1px solid var(--border);
|
|
121
|
+
border-radius: 12px;
|
|
122
|
+
padding: 1rem;
|
|
123
|
+
transition: transform 0.2s ease, border-color 0.2s ease;
|
|
124
|
+
display: flex;
|
|
125
|
+
flex-direction: column;
|
|
126
|
+
justify-content: space-between;
|
|
127
|
+
}
|
|
128
|
+
.file-card:hover { transform: translateY(-4px); border-color: var(--primary); }
|
|
129
|
+
.file-header {
|
|
130
|
+
display: flex;
|
|
131
|
+
justify-content: space-between;
|
|
132
|
+
align-items: flex-start;
|
|
133
|
+
margin-bottom: 1rem;
|
|
134
|
+
}
|
|
135
|
+
.file-path {
|
|
136
|
+
font-family: 'Fira Code', monospace;
|
|
137
|
+
font-size: 0.875rem;
|
|
138
|
+
word-break: break-all;
|
|
139
|
+
margin-right: 1rem;
|
|
140
|
+
color: var(--text);
|
|
141
|
+
}
|
|
142
|
+
.file-weight { font-size: 0.75rem; color: var(--text-dim); white-space: nowrap; }
|
|
143
|
+
.file-footer {
|
|
144
|
+
display: flex;
|
|
145
|
+
justify-content: space-between;
|
|
146
|
+
align-items: center;
|
|
147
|
+
margin-top: 1rem;
|
|
148
|
+
font-size: 0.75rem;
|
|
149
|
+
}
|
|
150
|
+
.op-badge {
|
|
151
|
+
background: #0f172a;
|
|
152
|
+
padding: 2px 6px;
|
|
153
|
+
border-radius: 4px;
|
|
154
|
+
color: var(--text-dim);
|
|
155
|
+
}
|
|
156
|
+
.turn-badge { color: var(--text-dim); }
|
|
157
|
+
.status-text { font-weight: bold; text-transform: uppercase; }
|
|
158
|
+
|
|
159
|
+
/* Status Colors */
|
|
160
|
+
.active { border-left: 4px solid var(--active); }
|
|
161
|
+
.active .status-text { color: var(--active); }
|
|
162
|
+
.stale { border-left: 4px solid var(--stale); }
|
|
163
|
+
.stale .status-text { color: var(--stale); }
|
|
164
|
+
.legacy { border-left: 4px solid var(--legacy); }
|
|
165
|
+
.legacy .status-text { color: var(--legacy); }
|
|
166
|
+
|
|
167
|
+
.weight-bar {
|
|
168
|
+
height: 4px;
|
|
169
|
+
background: #020617;
|
|
170
|
+
border-radius: 2px;
|
|
171
|
+
margin-top: 1rem;
|
|
172
|
+
overflow: hidden;
|
|
173
|
+
}
|
|
174
|
+
.weight-fill {
|
|
175
|
+
height: 100%;
|
|
176
|
+
background: var(--primary);
|
|
177
|
+
transition: width 0.3s ease;
|
|
178
|
+
}
|
|
179
|
+
</style>
|
|
180
|
+
</head>
|
|
181
|
+
<body>
|
|
182
|
+
<div class="container">
|
|
183
|
+
<header>
|
|
184
|
+
<h1>Pi Context Map</h1>
|
|
185
|
+
<p style="color: var(--text-dim)">Session context window visualization and token distribution.</p>
|
|
186
|
+
|
|
187
|
+
<div class="stats-grid">
|
|
188
|
+
<div class="stat-card">
|
|
189
|
+
<span class="stat-value">${map.totalTokens.toLocaleString()}</span>
|
|
190
|
+
<span class="stat-label">Total Tokens</span>
|
|
191
|
+
</div>
|
|
192
|
+
<div class="stat-card">
|
|
193
|
+
<span class="stat-value">${map.files.length}</span>
|
|
194
|
+
<span class="stat-label">Files in Context</span>
|
|
195
|
+
</div>
|
|
196
|
+
<div class="stat-card">
|
|
197
|
+
<span class="stat-value">${map.fileTokens.toLocaleString()}</span>
|
|
198
|
+
<span class="stat-label">File Tokens</span>
|
|
199
|
+
</div>
|
|
200
|
+
<div class="stat-card">
|
|
201
|
+
<span class="stat-value">${Math.round(budgetPercent)}%</span>
|
|
202
|
+
<span class="stat-label">File Load</span>
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
|
|
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>
|
|
212
|
+
</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>
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
</header>
|
|
221
|
+
|
|
222
|
+
<div class="file-grid">
|
|
223
|
+
${fileCards}
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
</body>
|
|
227
|
+
</html>
|
|
228
|
+
`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
public static writeReport(html: string): string {
|
|
232
|
+
const reportDir = join(homedir(), ".pi", "context-map");
|
|
233
|
+
mkdirSync(reportDir, { recursive: true });
|
|
234
|
+
const reportPath = join(reportDir, "report.html");
|
|
235
|
+
writeFileSync(reportPath, html, "utf8");
|
|
236
|
+
return reportPath;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private static getOpIcon(type: string): string {
|
|
240
|
+
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 "📄";
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private static escapeHtml(text: string): string {
|
|
255
|
+
const map = {
|
|
256
|
+
"&": "&",
|
|
257
|
+
"<": "<",
|
|
258
|
+
">": ">",
|
|
259
|
+
'"': """,
|
|
260
|
+
"'": "'",
|
|
261
|
+
};
|
|
262
|
+
return text.replace(/[&<>"']/g, (m) => map[m as keyof typeof map]);
|
|
263
|
+
}
|
|
264
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-context-map
|
|
3
|
+
* Pi extension to visualize session context window and token distribution.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { ContextAnalyzer } from "./analyzer";
|
|
8
|
+
import { ReportGenerator } from "./generator";
|
|
9
|
+
|
|
10
|
+
export default function (pi: ExtensionAPI) {
|
|
11
|
+
const analyzer = new ContextAnalyzer();
|
|
12
|
+
|
|
13
|
+
// Register the /context-map command
|
|
14
|
+
pi.registerCommand("context-map", {
|
|
15
|
+
description: "Generate a visual map of the current session context window.",
|
|
16
|
+
handler: (_args, ctx) => {
|
|
17
|
+
ctx.ui.notify("Analyzing session context...", "info");
|
|
18
|
+
|
|
19
|
+
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
|
+
);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
50
|
+
ctx.ui.notify(`Failed to generate context map: ${message}`, "error");
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
|
|
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;
|
|
59
|
+
|
|
60
|
+
if (tokens > 100_000) {
|
|
61
|
+
ctx.ui.notify(
|
|
62
|
+
`High context load detected (${(tokens / 1000).toFixed(1)}k tokens). Try /context-map to see what's consuming space.`,
|
|
63
|
+
"info",
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
declare module "@earendil-works/pi-ai" {
|
|
2
|
+
export async function complete(
|
|
3
|
+
model: any,
|
|
4
|
+
params: {
|
|
5
|
+
messages: any[];
|
|
6
|
+
},
|
|
7
|
+
options?: {
|
|
8
|
+
apiKey?: string;
|
|
9
|
+
headers?: Record<string, string>;
|
|
10
|
+
maxTokens?: number;
|
|
11
|
+
signal?: AbortSignal;
|
|
12
|
+
}
|
|
13
|
+
): Promise<{
|
|
14
|
+
content: Array<{ type: "text"; text: string }>;
|
|
15
|
+
}>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
}
|
|
31
|
+
}
|
package/tasks.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Implementation Tasks: pi-context-map
|
|
2
|
+
|
|
3
|
+
## Phase 1: Foundation
|
|
4
|
+
- [ ] Initialize `package.json` and TypeScript config.
|
|
5
|
+
- [ ] Create project folder structure (`src/`, `docs/`).
|
|
6
|
+
|
|
7
|
+
## Phase 2: Core Logic (`ContextAnalyzer`)
|
|
8
|
+
- [ ] Implement message scanning logic to find file operations.
|
|
9
|
+
- [ ] Implement token estimation heuristic.
|
|
10
|
+
- [ ] Implement status assignment (Active/Stale/Legacy).
|
|
11
|
+
- [ ] Create unit tests for analyzer logic.
|
|
12
|
+
|
|
13
|
+
## Phase 3: Visualization (`ReportGenerator`)
|
|
14
|
+
- [ ] Design the HTML dashboard template.
|
|
15
|
+
- [ ] Implement data-to-HTML mapping.
|
|
16
|
+
- [ ] Implement file writing to `.pi/context-map/report.html`.
|
|
17
|
+
|
|
18
|
+
## Phase 4: Pi Integration
|
|
19
|
+
- [ ] Register `/context-map` command.
|
|
20
|
+
- [ ] Implement command handler that triggers analysis $\to$ report $\to$ notification.
|
|
21
|
+
- [ ] Add "Open Report" link in the Pi notification.
|
|
22
|
+
|
|
23
|
+
## Phase 5: QA & Polishing
|
|
24
|
+
- [ ] Test with high-token sessions.
|
|
25
|
+
- [ ] Verify accuracy of token weights.
|
|
26
|
+
- [ ] Run Pi Lens diagnostics to ensure zero blockers.
|
|
27
|
+
- [ ] Polish HTML CSS for "Architect" look.
|
|
28
|
+
|
|
29
|
+
## Phase 6: Release
|
|
30
|
+
- [ ] Update README.md.
|
|
31
|
+
- [ ] Publish to npm.
|
|
32
|
+
- [ ] Final GitHub release.
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"rootDir": "./src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"declaration": true,
|
|
13
|
+
"baseUrl": ".",
|
|
14
|
+
"paths": {
|
|
15
|
+
"*": ["src/types/*"]
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"include": ["src/**/*"],
|
|
19
|
+
"typeRoots": ["./node_modules/@types", "src/types"]
|
|
20
|
+
}
|