pi-context-map 0.3.0 → 0.4.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 +18 -0
- package/README.md +26 -1
- package/extensions/generator.ts +442 -27
- package/extensions/index.ts +76 -14
- package/extensions/live-server.ts +283 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.0] - 2026-06-14
|
|
4
|
+
### Live Localhost Server
|
|
5
|
+
- **Live SSE Server**: New `LiveReportServer` binds to 127.0.0.1 on a free port and serves the report at `/`.
|
|
6
|
+
- **Auto-Updates**: Server-Sent Events endpoint at `/events` pushes the latest HTML whenever the analysis re-runs (e.g., after each assistant message).
|
|
7
|
+
- **Token Auth**: Each server instance generates a unique session token; the HTML client picks it up via a `<meta>` tag and includes it in the SSE URL to prevent unauthorized access.
|
|
8
|
+
- **Origin Validation**: Only connections from `http://127.0.0.1:<port>` or `http://localhost:<port>` are allowed.
|
|
9
|
+
- **Graceful Shutdown**: `/context-map stop` or `session_shutdown` event stops the server cleanly.
|
|
10
|
+
- **Auto-Refresh**: The `message_end` event triggers an automatic re-analysis when the live server is running, so the browser view stays in sync.
|
|
11
|
+
- **Health & Stop Endpoints**: `/health` for liveness, `POST /stop` for remote termination.
|
|
12
|
+
|
|
13
|
+
## [0.3.1] - 2026-06-14
|
|
14
|
+
### Design & Interactivity Upgrade
|
|
15
|
+
- **Linear Design System**: Refactored CSS to use the Linear design tokens (canvas #010102, accent #5e6ad2) for a professional, near-black aesthetic.
|
|
16
|
+
- **shadcn/ui Card Patterns**: Insight cards now follow shadcn conventions (hairline borders, gradient backgrounds for severity).
|
|
17
|
+
- **Collapsible Insights**: Critical and warning insights are expanded by default; info insights are collapsed. Click to toggle.
|
|
18
|
+
- **File Search & Filter**: Added a real-time search input and status filter dropdown above the file grid. Shows match count and empty state.
|
|
19
|
+
- **Design Doc**: Added `docs/design.md` documenting the visual language, layout, and accessibility decisions.
|
|
20
|
+
|
|
3
21
|
## [0.3.0] - 2026-06-14
|
|
4
22
|
### Professional Context Profiler
|
|
5
23
|
- **Code-Aware Token Counting**: New `TokenCounter` module applies multipliers for code blocks (1.3x) and JSON (1.5x) for more accurate estimation.
|
package/README.md
CHANGED
|
@@ -46,7 +46,32 @@ The extension categorizes files to help you manage context bloat:
|
|
|
46
46
|
1. **Scanning**: The analyzer iterates through the session history, identifying all `tool_use` calls involving file operations.
|
|
47
47
|
2. **Weighting**: It extracts the content length of tool results and applies a token heuristic (approx. 4 chars/token).
|
|
48
48
|
3. **Categorization**: It calculates the temporal distance between the current turn and the last file access.
|
|
49
|
-
4. **Visualization**: It generates a standalone HTML dashboard featuring a
|
|
49
|
+
4. **Visualization**: It generates a standalone HTML dashboard featuring a stacked composition bar, a file-weight grid with search/filter, and an interactive insights section.
|
|
50
|
+
|
|
51
|
+
## Live Localhost Server
|
|
52
|
+
|
|
53
|
+
When the extension loads, it automatically starts a local HTTP server on `127.0.0.1` (a random free port). The server:
|
|
54
|
+
|
|
55
|
+
- Serves the current report at `http://127.0.0.1:<port>/`.
|
|
56
|
+
- Pushes live updates via Server-Sent Events at `/events?token=<sessionToken>`.
|
|
57
|
+
- Authenticates the SSE connection with a per-session token (injected into the HTML as a `<meta>` tag).
|
|
58
|
+
- Auto-refreshes after each assistant message, so the browser view stays in sync.
|
|
59
|
+
|
|
60
|
+
**Commands:**
|
|
61
|
+
|
|
62
|
+
- `/context-map` — Generate a fresh report and broadcast it to the browser.
|
|
63
|
+
- `/context-map stop` — Stop the live server.
|
|
64
|
+
|
|
65
|
+
**Endpoints:**
|
|
66
|
+
|
|
67
|
+
- `GET /` or `/report.html` — The current report HTML.
|
|
68
|
+
- `GET /events?token=...` — Server-Sent Events stream of updates.
|
|
69
|
+
- `GET /health` — Returns `{ "status": "ok", "port": <number> }`.
|
|
70
|
+
- `POST /stop` — Gracefully stops the server.
|
|
71
|
+
|
|
72
|
+
## Design
|
|
73
|
+
|
|
74
|
+
The report uses the **Linear design system** (canvas `#010102`, accent `#5e6ad2`) with **shadcn/ui card patterns**. See `docs/design.md` for the full specification. The output is a single self-contained HTML file with no external dependencies.
|
|
50
75
|
|
|
51
76
|
## Compatibility
|
|
52
77
|
|
package/extensions/generator.ts
CHANGED
|
@@ -17,7 +17,7 @@ export class ReportGenerator {
|
|
|
17
17
|
const fileCards = composition.files_detail
|
|
18
18
|
.map(
|
|
19
19
|
(file) => `
|
|
20
|
-
<div class="file-card ${file.status}">
|
|
20
|
+
<div class="file-card ${file.status}" data-path="${ReportGenerator.escapeHtml(file.path)}" data-status="${file.status}">
|
|
21
21
|
<div class="file-header">
|
|
22
22
|
<span class="file-path">${ReportGenerator.escapeHtml(file.path)}</span>
|
|
23
23
|
<span class="file-weight">${file.weight.toLocaleString()} tokens</span>
|
|
@@ -36,18 +36,23 @@ export class ReportGenerator {
|
|
|
36
36
|
.join("");
|
|
37
37
|
|
|
38
38
|
const insightCards = insights
|
|
39
|
-
.map(
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
.map((insight, i) => {
|
|
40
|
+
// Critical and warning are expanded by default; info is collapsed
|
|
41
|
+
const isCollapsed = insight.severity === "info" ? " collapsed" : "";
|
|
42
|
+
return `
|
|
43
|
+
<div class="insight-card ${insight.severity}${isCollapsed}">
|
|
44
|
+
<button class="insight-header" data-toggle="insight-${i}" aria-expanded="${isCollapsed ? "false" : "true"}">
|
|
45
|
+
<svg class="insight-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4,6 8,10 12,6"/></svg>
|
|
43
46
|
<span class="insight-severity">${insight.severity.toUpperCase()}</span>
|
|
44
47
|
<span class="insight-title">${ReportGenerator.escapeHtml(insight.title)}</span>
|
|
48
|
+
</button>
|
|
49
|
+
<div class="insight-body">
|
|
50
|
+
${ReportGenerator.escapeHtml(insight.message)}
|
|
51
|
+
${insight.command ? `<div class="insight-command">Suggested: <code>${insight.command}</code></div>` : ""}
|
|
45
52
|
</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
53
|
</div>
|
|
49
|
-
|
|
50
|
-
)
|
|
54
|
+
`;
|
|
55
|
+
})
|
|
51
56
|
.join("");
|
|
52
57
|
|
|
53
58
|
return `
|
|
@@ -56,19 +61,333 @@ export class ReportGenerator {
|
|
|
56
61
|
<head>
|
|
57
62
|
<meta charset="UTF-8">
|
|
58
63
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
59
|
-
<title>Pi Context
|
|
64
|
+
<title>Pi Context Profiler</title>
|
|
60
65
|
<style>
|
|
66
|
+
/* ============================================
|
|
67
|
+
pi-context-map Report — Design Tokens
|
|
68
|
+
Based on Linear design system + shadcn/ui card patterns
|
|
69
|
+
============================================ */
|
|
61
70
|
:root {
|
|
62
|
-
|
|
63
|
-
--
|
|
64
|
-
--
|
|
65
|
-
--
|
|
66
|
-
--
|
|
67
|
-
--
|
|
68
|
-
--
|
|
69
|
-
|
|
70
|
-
|
|
71
|
+
/* Surfaces */
|
|
72
|
+
--canvas: #010102;
|
|
73
|
+
--surface-1: #0f1011;
|
|
74
|
+
--surface-2: #141516;
|
|
75
|
+
--surface-3: #18191a;
|
|
76
|
+
--hairline: #23252a;
|
|
77
|
+
--hairline-strong: #34343a;
|
|
78
|
+
|
|
79
|
+
/* Text */
|
|
80
|
+
--ink: #f7f8f8;
|
|
81
|
+
--ink-muted: #d0d6e0;
|
|
82
|
+
--ink-subtle: #8a8f98;
|
|
83
|
+
--ink-tertiary: #62666d;
|
|
84
|
+
|
|
85
|
+
/* Accent */
|
|
86
|
+
--accent: #5e6ad2;
|
|
87
|
+
--accent-hover: #828fff;
|
|
88
|
+
--accent-soft: rgba(94, 106, 210, 0.12);
|
|
89
|
+
|
|
90
|
+
/* Semantic */
|
|
91
|
+
--success: #27a644;
|
|
92
|
+
--warning: #eab308;
|
|
93
|
+
--danger: #ef4444;
|
|
94
|
+
--warning-soft: rgba(234, 179, 8, 0.10);
|
|
95
|
+
--danger-soft: rgba(239, 68, 68, 0.10);
|
|
96
|
+
|
|
97
|
+
/* Composition segments */
|
|
98
|
+
--seg-system: #6366f1;
|
|
99
|
+
--seg-tools: #ec4899;
|
|
100
|
+
--seg-history: #a855f7;
|
|
101
|
+
--seg-files: #38bdf8;
|
|
102
|
+
--seg-summaries: #14b8a6;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
106
|
+
|
|
107
|
+
body {
|
|
108
|
+
background: var(--canvas);
|
|
109
|
+
color: var(--ink);
|
|
110
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
|
111
|
+
font-size: 14px;
|
|
112
|
+
line-height: 1.5;
|
|
113
|
+
-webkit-font-smoothing: antialiased;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.container { max-width: 1200px; margin: 0 auto; padding: 48px 32px; }
|
|
117
|
+
|
|
118
|
+
/* ===== Header ===== */
|
|
119
|
+
header { margin-bottom: 48px; }
|
|
120
|
+
h1 {
|
|
121
|
+
font-size: 32px;
|
|
122
|
+
font-weight: 600;
|
|
123
|
+
letter-spacing: -0.8px;
|
|
124
|
+
margin-bottom: 8px;
|
|
125
|
+
color: var(--ink);
|
|
126
|
+
}
|
|
127
|
+
.subtitle { color: var(--ink-subtle); font-size: 14px; margin-bottom: 32px; }
|
|
128
|
+
|
|
129
|
+
.stats-grid {
|
|
130
|
+
display: grid;
|
|
131
|
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
132
|
+
gap: 16px;
|
|
133
|
+
}
|
|
134
|
+
.stat-card {
|
|
135
|
+
background: var(--surface-1);
|
|
136
|
+
border: 1px solid var(--hairline);
|
|
137
|
+
border-radius: 6px;
|
|
138
|
+
padding: 20px;
|
|
139
|
+
text-align: left;
|
|
140
|
+
}
|
|
141
|
+
.stat-value {
|
|
142
|
+
font-size: 24px;
|
|
143
|
+
font-weight: 600;
|
|
144
|
+
color: var(--ink);
|
|
145
|
+
display: block;
|
|
146
|
+
font-variant-numeric: tabular-nums;
|
|
147
|
+
}
|
|
148
|
+
.stat-label {
|
|
149
|
+
color: var(--ink-subtle);
|
|
150
|
+
font-size: 12px;
|
|
151
|
+
text-transform: uppercase;
|
|
152
|
+
letter-spacing: 0.5px;
|
|
153
|
+
margin-top: 4px;
|
|
154
|
+
display: block;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* ===== Composition Bar ===== */
|
|
158
|
+
.composition-container {
|
|
159
|
+
background: var(--surface-1);
|
|
160
|
+
border: 1px solid var(--hairline);
|
|
161
|
+
border-radius: 6px;
|
|
162
|
+
padding: 20px;
|
|
163
|
+
margin-top: 24px;
|
|
164
|
+
}
|
|
165
|
+
.composition-bar {
|
|
166
|
+
height: 32px;
|
|
167
|
+
background: var(--surface-3);
|
|
168
|
+
border-radius: 4px;
|
|
169
|
+
display: flex;
|
|
170
|
+
overflow: hidden;
|
|
171
|
+
margin-bottom: 12px;
|
|
172
|
+
}
|
|
173
|
+
.composition-segment {
|
|
174
|
+
height: 100%;
|
|
175
|
+
transition: opacity 0.2s ease;
|
|
176
|
+
cursor: default;
|
|
177
|
+
}
|
|
178
|
+
.composition-segment:hover { opacity: 0.85; }
|
|
179
|
+
.seg-system { background: var(--seg-system); }
|
|
180
|
+
.seg-tools { background: var(--seg-tools); }
|
|
181
|
+
.seg-history { background: var(--seg-history); }
|
|
182
|
+
.seg-files { background: var(--seg-files); }
|
|
183
|
+
.seg-summaries { background: var(--seg-summaries); }
|
|
184
|
+
|
|
185
|
+
.composition-legend {
|
|
186
|
+
display: grid;
|
|
187
|
+
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
188
|
+
gap: 8px;
|
|
189
|
+
font-size: 12px;
|
|
190
|
+
}
|
|
191
|
+
.legend-item {
|
|
192
|
+
display: flex;
|
|
193
|
+
align-items: center;
|
|
194
|
+
gap: 8px;
|
|
195
|
+
color: var(--ink-muted);
|
|
196
|
+
font-variant-numeric: tabular-nums;
|
|
197
|
+
}
|
|
198
|
+
.dot { width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0; }
|
|
199
|
+
|
|
200
|
+
/* ===== Sections ===== */
|
|
201
|
+
h2 {
|
|
202
|
+
font-size: 20px;
|
|
203
|
+
font-weight: 600;
|
|
204
|
+
color: var(--ink);
|
|
205
|
+
margin: 48px 0 16px;
|
|
206
|
+
letter-spacing: -0.3px;
|
|
207
|
+
}
|
|
208
|
+
h3 {
|
|
209
|
+
font-size: 12px;
|
|
210
|
+
font-weight: 500;
|
|
211
|
+
color: var(--ink-subtle);
|
|
212
|
+
text-transform: uppercase;
|
|
213
|
+
letter-spacing: 0.8px;
|
|
214
|
+
margin-bottom: 12px;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/* ===== Insights (shadcn-style cards) ===== */
|
|
218
|
+
.insight-card {
|
|
219
|
+
background: var(--surface-1);
|
|
220
|
+
border: 1px solid var(--hairline);
|
|
221
|
+
border-left: 3px solid var(--accent);
|
|
222
|
+
border-radius: 6px;
|
|
223
|
+
margin-bottom: 8px;
|
|
224
|
+
overflow: hidden;
|
|
225
|
+
}
|
|
226
|
+
.insight-card.critical { border-left-color: var(--danger); background: linear-gradient(90deg, var(--danger-soft) 0%, var(--surface-1) 100%); }
|
|
227
|
+
.insight-card.warning { border-left-color: var(--warning); background: linear-gradient(90deg, var(--warning-soft) 0%, var(--surface-1) 100%); }
|
|
228
|
+
.insight-card.info { border-left-color: var(--accent); }
|
|
229
|
+
|
|
230
|
+
.insight-header {
|
|
231
|
+
display: flex;
|
|
232
|
+
align-items: center;
|
|
233
|
+
gap: 12px;
|
|
234
|
+
padding: 14px 16px;
|
|
235
|
+
cursor: pointer;
|
|
236
|
+
user-select: none;
|
|
237
|
+
background: none;
|
|
238
|
+
border: none;
|
|
239
|
+
width: 100%;
|
|
240
|
+
text-align: left;
|
|
241
|
+
color: inherit;
|
|
242
|
+
font: inherit;
|
|
243
|
+
}
|
|
244
|
+
.insight-header:hover { background: var(--surface-2); }
|
|
245
|
+
.insight-chevron {
|
|
246
|
+
width: 16px;
|
|
247
|
+
height: 16px;
|
|
248
|
+
transition: transform 0.2s ease;
|
|
249
|
+
color: var(--ink-subtle);
|
|
250
|
+
flex-shrink: 0;
|
|
251
|
+
}
|
|
252
|
+
.insight-card.collapsed .insight-chevron { transform: rotate(-90deg); }
|
|
253
|
+
.insight-severity {
|
|
254
|
+
font-size: 10px;
|
|
255
|
+
font-weight: 700;
|
|
256
|
+
padding: 3px 8px;
|
|
257
|
+
border-radius: 3px;
|
|
258
|
+
background: var(--surface-3);
|
|
259
|
+
color: var(--ink-muted);
|
|
260
|
+
letter-spacing: 0.5px;
|
|
261
|
+
flex-shrink: 0;
|
|
262
|
+
}
|
|
263
|
+
.insight-card.critical .insight-severity { color: var(--danger); }
|
|
264
|
+
.insight-card.warning .insight-severity { color: var(--warning); }
|
|
265
|
+
.insight-card.info .insight-severity { color: var(--accent); }
|
|
266
|
+
.insight-title { font-weight: 600; color: var(--ink); font-size: 14px; }
|
|
267
|
+
.insight-body {
|
|
268
|
+
padding: 0 16px 14px 44px;
|
|
269
|
+
color: var(--ink-muted);
|
|
270
|
+
font-size: 13px;
|
|
271
|
+
line-height: 1.6;
|
|
272
|
+
}
|
|
273
|
+
.insight-card.collapsed .insight-body { display: none; }
|
|
274
|
+
.insight-command {
|
|
275
|
+
margin-top: 8px;
|
|
276
|
+
font-size: 12px;
|
|
277
|
+
color: var(--ink-subtle);
|
|
278
|
+
}
|
|
279
|
+
.insight-command code {
|
|
280
|
+
background: var(--surface-3);
|
|
281
|
+
color: var(--accent-hover);
|
|
282
|
+
padding: 2px 6px;
|
|
283
|
+
border-radius: 3px;
|
|
284
|
+
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
|
|
285
|
+
font-size: 12px;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/* ===== File Controls ===== */
|
|
289
|
+
.file-controls {
|
|
290
|
+
display: flex;
|
|
291
|
+
gap: 12px;
|
|
292
|
+
margin-bottom: 16px;
|
|
293
|
+
flex-wrap: wrap;
|
|
294
|
+
}
|
|
295
|
+
.file-search, .file-filter {
|
|
296
|
+
background: var(--surface-1);
|
|
297
|
+
border: 1px solid var(--hairline);
|
|
298
|
+
border-radius: 6px;
|
|
299
|
+
padding: 8px 12px;
|
|
300
|
+
color: var(--ink);
|
|
301
|
+
font: inherit;
|
|
302
|
+
font-size: 13px;
|
|
303
|
+
outline: none;
|
|
304
|
+
transition: border-color 0.15s ease;
|
|
305
|
+
}
|
|
306
|
+
.file-search:focus, .file-filter:focus { border-color: var(--accent); }
|
|
307
|
+
.file-search { flex: 1; min-width: 200px; }
|
|
308
|
+
.file-search::placeholder { color: var(--ink-tertiary); }
|
|
309
|
+
.file-filter { cursor: pointer; }
|
|
310
|
+
.file-count { color: var(--ink-subtle); font-size: 12px; padding: 8px 0; align-self: center; }
|
|
311
|
+
|
|
312
|
+
/* ===== File Grid ===== */
|
|
313
|
+
.file-grid {
|
|
314
|
+
display: grid;
|
|
315
|
+
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
|
316
|
+
gap: 12px;
|
|
317
|
+
}
|
|
318
|
+
.file-card {
|
|
319
|
+
background: var(--surface-1);
|
|
320
|
+
border: 1px solid var(--hairline);
|
|
321
|
+
border-radius: 6px;
|
|
322
|
+
padding: 14px 16px;
|
|
323
|
+
transition: border-color 0.15s ease;
|
|
324
|
+
}
|
|
325
|
+
.file-card:hover { border-color: var(--hairline-strong); }
|
|
326
|
+
.file-card.hidden { display: none; }
|
|
327
|
+
.file-header {
|
|
328
|
+
display: flex;
|
|
329
|
+
justify-content: space-between;
|
|
330
|
+
align-items: flex-start;
|
|
331
|
+
gap: 8px;
|
|
332
|
+
margin-bottom: 10px;
|
|
333
|
+
}
|
|
334
|
+
.file-path {
|
|
335
|
+
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
|
|
336
|
+
font-size: 12px;
|
|
337
|
+
color: var(--ink);
|
|
338
|
+
word-break: break-all;
|
|
339
|
+
line-height: 1.4;
|
|
71
340
|
}
|
|
341
|
+
.file-weight {
|
|
342
|
+
font-size: 11px;
|
|
343
|
+
color: var(--ink-subtle);
|
|
344
|
+
white-space: nowrap;
|
|
345
|
+
font-variant-numeric: tabular-nums;
|
|
346
|
+
flex-shrink: 0;
|
|
347
|
+
}
|
|
348
|
+
.file-footer {
|
|
349
|
+
display: flex;
|
|
350
|
+
justify-content: space-between;
|
|
351
|
+
align-items: center;
|
|
352
|
+
font-size: 11px;
|
|
353
|
+
color: var(--ink-subtle);
|
|
354
|
+
text-transform: uppercase;
|
|
355
|
+
letter-spacing: 0.5px;
|
|
356
|
+
}
|
|
357
|
+
.op-badge {
|
|
358
|
+
background: var(--surface-3);
|
|
359
|
+
padding: 2px 6px;
|
|
360
|
+
border-radius: 3px;
|
|
361
|
+
color: var(--ink-muted);
|
|
362
|
+
}
|
|
363
|
+
.status-text { font-weight: 700; }
|
|
364
|
+
.file-card.active { border-left: 3px solid var(--success); }
|
|
365
|
+
.file-card.active .status-text { color: var(--success); }
|
|
366
|
+
.file-card.stale { border-left: 3px solid var(--warning); }
|
|
367
|
+
.file-card.stale .status-text { color: var(--warning); }
|
|
368
|
+
.file-card.legacy { border-left: 3px solid var(--danger); }
|
|
369
|
+
.file-card.legacy .status-text { color: var(--danger); }
|
|
370
|
+
|
|
371
|
+
.weight-bar {
|
|
372
|
+
height: 3px;
|
|
373
|
+
background: var(--surface-3);
|
|
374
|
+
border-radius: 2px;
|
|
375
|
+
margin-top: 10px;
|
|
376
|
+
overflow: hidden;
|
|
377
|
+
}
|
|
378
|
+
.weight-fill {
|
|
379
|
+
height: 100%;
|
|
380
|
+
background: var(--accent);
|
|
381
|
+
transition: width 0.3s ease;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
.empty-state {
|
|
385
|
+
text-align: center;
|
|
386
|
+
padding: 48px 16px;
|
|
387
|
+
color: var(--ink-subtle);
|
|
388
|
+
font-size: 13px;
|
|
389
|
+
}
|
|
390
|
+
</style>
|
|
72
391
|
body {
|
|
73
392
|
background: var(--bg);
|
|
74
393
|
color: var(--text);
|
|
@@ -275,10 +594,101 @@ export class ReportGenerator {
|
|
|
275
594
|
${insightCards}
|
|
276
595
|
</section>
|
|
277
596
|
|
|
278
|
-
<
|
|
279
|
-
|
|
280
|
-
|
|
597
|
+
<section>
|
|
598
|
+
<h2>Files in Context</h2>
|
|
599
|
+
<div class="file-controls">
|
|
600
|
+
<input type="text" class="file-search" id="fileSearch" placeholder="Search files by path..." aria-label="Search files" />
|
|
601
|
+
<select class="file-filter" id="fileFilter" aria-label="Filter by status">
|
|
602
|
+
<option value="all">All statuses</option>
|
|
603
|
+
<option value="active">Active</option>
|
|
604
|
+
<option value="stale">Stale</option>
|
|
605
|
+
<option value="legacy">Legacy</option>
|
|
606
|
+
</select>
|
|
607
|
+
<span class="file-count" id="fileCount"></span>
|
|
608
|
+
</div>
|
|
609
|
+
<div class="file-grid" id="fileGrid">
|
|
610
|
+
${fileCards}
|
|
611
|
+
</div>
|
|
612
|
+
<div class="empty-state" id="emptyState" style="display: none;">No files match your search.</div>
|
|
613
|
+
</section>
|
|
281
614
|
</div>
|
|
615
|
+
|
|
616
|
+
<script>
|
|
617
|
+
(function() {
|
|
618
|
+
// ===== Live update via Server-Sent Events =====
|
|
619
|
+
// Token is injected into the script via a meta tag (set by the server).
|
|
620
|
+
// Connect to /events?token=...; when the server pushes a new html payload, replace the document.
|
|
621
|
+
try {
|
|
622
|
+
var tokenMeta = document.querySelector('meta[name="context-map-token"]');
|
|
623
|
+
var token = tokenMeta ? tokenMeta.getAttribute('content') : '';
|
|
624
|
+
var evtSource = new EventSource('/events?token=' + encodeURIComponent(token));
|
|
625
|
+
evtSource.onmessage = function(e) {
|
|
626
|
+
try {
|
|
627
|
+
var payload = JSON.parse(e.data);
|
|
628
|
+
if (payload.html) {
|
|
629
|
+
// Replace the document body with the new HTML
|
|
630
|
+
var parser = new DOMParser();
|
|
631
|
+
var newDoc = parser.parseFromString(payload.html, 'text/html');
|
|
632
|
+
document.documentElement.replaceChild(
|
|
633
|
+
document.importNode(newDoc.documentElement, true),
|
|
634
|
+
document.documentElement
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
} catch (err) {
|
|
638
|
+
console.warn('Failed to apply live update:', err);
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
evtSource.onerror = function() {
|
|
642
|
+
// Silently close on error; user can refresh the page to reconnect
|
|
643
|
+
evtSource.close();
|
|
644
|
+
};
|
|
645
|
+
} catch (err) {
|
|
646
|
+
// EventSource not available; fall back to manual refresh
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// ===== Insight collapse/expand =====
|
|
650
|
+
document.querySelectorAll('.insight-header[data-toggle]').forEach(function(btn) {
|
|
651
|
+
btn.addEventListener('click', function() {
|
|
652
|
+
var card = btn.closest('.insight-card');
|
|
653
|
+
var isCollapsed = card.classList.toggle('collapsed');
|
|
654
|
+
btn.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true');
|
|
655
|
+
});
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
// ===== File search & filter =====
|
|
659
|
+
var search = document.getElementById('fileSearch');
|
|
660
|
+
var filter = document.getElementById('fileFilter');
|
|
661
|
+
var grid = document.getElementById('fileGrid');
|
|
662
|
+
var count = document.getElementById('fileCount');
|
|
663
|
+
var empty = document.getElementById('emptyState');
|
|
664
|
+
var cards = grid ? Array.prototype.slice.call(grid.querySelectorAll('.file-card')) : [];
|
|
665
|
+
var total = cards.length;
|
|
666
|
+
|
|
667
|
+
function applyFilters() {
|
|
668
|
+
var query = (search.value || '').toLowerCase();
|
|
669
|
+
var status = filter.value;
|
|
670
|
+
var visible = 0;
|
|
671
|
+
cards.forEach(function(card) {
|
|
672
|
+
var path = (card.getAttribute('data-path') || '').toLowerCase();
|
|
673
|
+
var cardStatus = card.getAttribute('data-status') || '';
|
|
674
|
+
var matchQuery = !query || path.indexOf(query) !== -1;
|
|
675
|
+
var matchStatus = status === 'all' || cardStatus === status;
|
|
676
|
+
if (matchQuery && matchStatus) {
|
|
677
|
+
card.classList.remove('hidden');
|
|
678
|
+
visible++;
|
|
679
|
+
} else {
|
|
680
|
+
card.classList.add('hidden');
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
count.textContent = visible === total ? total + ' files' : visible + ' of ' + total + ' files';
|
|
684
|
+
empty.style.display = visible === 0 ? 'block' : 'none';
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if (search) search.addEventListener('input', applyFilters);
|
|
688
|
+
if (filter) filter.addEventListener('change', applyFilters);
|
|
689
|
+
applyFilters();
|
|
690
|
+
})();
|
|
691
|
+
</script>
|
|
282
692
|
</body>
|
|
283
693
|
</html>
|
|
284
694
|
`;
|
|
@@ -294,11 +704,16 @@ export class ReportGenerator {
|
|
|
294
704
|
|
|
295
705
|
private static getOpIcon(type: string): string {
|
|
296
706
|
switch (type) {
|
|
297
|
-
case "read":
|
|
298
|
-
|
|
299
|
-
case "
|
|
300
|
-
|
|
301
|
-
|
|
707
|
+
case "read":
|
|
708
|
+
return "READ";
|
|
709
|
+
case "write":
|
|
710
|
+
return "WRITE";
|
|
711
|
+
case "edit":
|
|
712
|
+
return "EDIT";
|
|
713
|
+
case "delete":
|
|
714
|
+
return "DELETE";
|
|
715
|
+
default:
|
|
716
|
+
return "FILE";
|
|
302
717
|
}
|
|
303
718
|
}
|
|
304
719
|
|
package/extensions/index.ts
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* pi-context-map
|
|
3
3
|
* Professional Context Profiler for Pi.
|
|
4
|
+
* v0.4.0 - Adds live localhost server with auto-updates.
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
7
8
|
import { ContextAnalyzer } from "./analyzer";
|
|
8
9
|
import { ReportGenerator } from "./generator";
|
|
9
10
|
import { InsightEngine } from "./insights";
|
|
11
|
+
import { LiveReportServer } from "./live-server";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import * as os from "node:os";
|
|
14
|
+
|
|
15
|
+
const REPORT_PATH = path.join(os.homedir(), ".pi", "context-map", "report.html");
|
|
10
16
|
|
|
11
17
|
export default async function piContextMap(pi: ExtensionAPI) {
|
|
12
18
|
const analyzer = new ContextAnalyzer();
|
|
19
|
+
const liveServer = new LiveReportServer();
|
|
13
20
|
|
|
14
21
|
async function runAnalysis() {
|
|
15
22
|
const messages = (pi as any).session?.messages || [];
|
|
@@ -17,31 +24,61 @@ export default async function piContextMap(pi: ExtensionAPI) {
|
|
|
17
24
|
const composition = analyzer.analyzeByType(messages, currentTurn);
|
|
18
25
|
const insights = InsightEngine.generate(composition);
|
|
19
26
|
const html = ReportGenerator.generateHTML(composition, insights);
|
|
20
|
-
|
|
21
|
-
|
|
27
|
+
|
|
28
|
+
// Write to disk (for offline access / persistence)
|
|
29
|
+
try {
|
|
30
|
+
const fs = await import("node:fs");
|
|
31
|
+
const dir = path.dirname(REPORT_PATH);
|
|
32
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
33
|
+
fs.writeFileSync(REPORT_PATH, html, "utf8");
|
|
34
|
+
} catch (err: any) {
|
|
35
|
+
console.error(`[pi-context-map] Failed to write report to disk: ${err.message}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Push to live server (if running) so the browser updates instantly
|
|
39
|
+
if (liveServer.isRunning) {
|
|
40
|
+
liveServer.update(html, REPORT_PATH);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { composition, insights, reportPath: REPORT_PATH };
|
|
22
44
|
}
|
|
23
45
|
|
|
46
|
+
// Start the live server on load
|
|
47
|
+
const serverUrl = await liveServer.start();
|
|
48
|
+
|
|
24
49
|
pi.registerCommand("context-map", {
|
|
25
|
-
description: "Generate a visual context map with actionable insights.",
|
|
26
|
-
handler: async (
|
|
50
|
+
description: "Generate a visual context map with actionable insights. Use 'stop' to terminate the live server.",
|
|
51
|
+
handler: async (args: any, ctx: any) => {
|
|
52
|
+
// Handle subcommand: /context-map stop
|
|
53
|
+
if (typeof args === "string" && args.trim().toLowerCase() === "stop") {
|
|
54
|
+
liveServer.stop();
|
|
55
|
+
ctx.ui.notify("Live server stopped.", "info");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
27
59
|
ctx.ui.notify("Analyzing session context...", "info");
|
|
28
60
|
try {
|
|
29
|
-
const {
|
|
30
|
-
const criticalCount = insights.filter((i) => i.severity === "critical").length;
|
|
61
|
+
const { insights, reportPath } = await runAnalysis();
|
|
62
|
+
const criticalCount = insights.filter((i: any) => i.severity === "critical").length;
|
|
31
63
|
const summary = criticalCount > 0
|
|
32
64
|
? `Context map generated. ${criticalCount} critical insight(s) found.`
|
|
33
65
|
: `Context map generated successfully.`;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
66
|
+
|
|
67
|
+
let details = `File: ${reportPath}`;
|
|
68
|
+
if (serverUrl) {
|
|
69
|
+
details += ` Live: ${serverUrl}`;
|
|
70
|
+
}
|
|
71
|
+
ctx.ui.notify(`${summary} ${details}`, criticalCount > 0 ? "warning" : "success");
|
|
72
|
+
} catch (error: any) {
|
|
73
|
+
ctx.ui.notify(`Failed to generate context map: ${error.message}`, "error");
|
|
38
74
|
}
|
|
39
75
|
},
|
|
40
76
|
});
|
|
41
77
|
|
|
42
78
|
pi.registerTool({
|
|
43
79
|
name: "context-map",
|
|
44
|
-
description:
|
|
80
|
+
description:
|
|
81
|
+
"Analyze the current session context composition and return actionable insights. The live localhost report will auto-update.",
|
|
45
82
|
parameters: {
|
|
46
83
|
type: "object",
|
|
47
84
|
properties: {},
|
|
@@ -49,7 +86,8 @@ export default async function piContextMap(pi: ExtensionAPI) {
|
|
|
49
86
|
handler: async (_ctx: any, _args: any) => {
|
|
50
87
|
try {
|
|
51
88
|
const { composition, insights } = await runAnalysis();
|
|
52
|
-
const summary =
|
|
89
|
+
const summary =
|
|
90
|
+
`Context: ${composition.total.tokens.toLocaleString()} tokens total. ` +
|
|
53
91
|
`System ${composition.system.percent}%, Tools ${composition.tools.percent}%, ` +
|
|
54
92
|
`History ${composition.history.percent}%, Files ${composition.files.percent}%, ` +
|
|
55
93
|
`Summaries ${composition.summaries.percent}%. ` +
|
|
@@ -64,12 +102,14 @@ export default async function piContextMap(pi: ExtensionAPI) {
|
|
|
64
102
|
summaries: composition.summaries.tokens,
|
|
65
103
|
total: composition.total.tokens,
|
|
66
104
|
},
|
|
67
|
-
insights: insights.map((i) => ({
|
|
105
|
+
insights: insights.map((i: any) => ({
|
|
68
106
|
severity: i.severity,
|
|
69
107
|
title: i.title,
|
|
70
108
|
message: i.message,
|
|
71
109
|
command: i.command,
|
|
72
110
|
})),
|
|
111
|
+
liveUrl: serverUrl,
|
|
112
|
+
reportPath: REPORT_PATH,
|
|
73
113
|
};
|
|
74
114
|
} catch (error: any) {
|
|
75
115
|
return { error: error.message };
|
|
@@ -78,7 +118,7 @@ export default async function piContextMap(pi: ExtensionAPI) {
|
|
|
78
118
|
});
|
|
79
119
|
|
|
80
120
|
pi.on("session_before_compact", (event: any, ctx: any) => {
|
|
81
|
-
const tokens =
|
|
121
|
+
const tokens = event?.preparation?.tokensBefore;
|
|
82
122
|
if (tokens && tokens > 100_000) {
|
|
83
123
|
ctx.ui.notify(
|
|
84
124
|
`High context load detected (${(tokens / 1000).toFixed(1)}k tokens). Try /context-map to see what's consuming space.`,
|
|
@@ -86,4 +126,26 @@ export default async function piContextMap(pi: ExtensionAPI) {
|
|
|
86
126
|
);
|
|
87
127
|
}
|
|
88
128
|
});
|
|
129
|
+
|
|
130
|
+
// Auto-refresh: re-run analysis after each assistant message so the live view stays current
|
|
131
|
+
pi.on("message_end", async (event: any) => {
|
|
132
|
+
if (event?.message?.role === "assistant" && liveServer.isRunning) {
|
|
133
|
+
try {
|
|
134
|
+
await runAnalysis();
|
|
135
|
+
} catch {
|
|
136
|
+
// Silently ignore auto-refresh failures
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Graceful shutdown: stop the live server when the session ends
|
|
142
|
+
pi.on("session_shutdown", () => {
|
|
143
|
+
liveServer.stop();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Log the live URL once on startup
|
|
147
|
+
if (serverUrl) {
|
|
148
|
+
console.log(`[pi-context-map] Live server running at ${serverUrl}`);
|
|
149
|
+
console.log(`[pi-context-map] Run /context-map to generate a report, or /context-map stop to terminate.`);
|
|
150
|
+
}
|
|
89
151
|
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LiveReportServer
|
|
3
|
+
* Serves the context map HTML report on a local HTTP server with live updates via SSE.
|
|
4
|
+
*
|
|
5
|
+
* Features:
|
|
6
|
+
* - Auto-assigns a free port (pass 0 to OS).
|
|
7
|
+
* - Binds to 127.0.0.1 only (no external access).
|
|
8
|
+
* - Serves the current report HTML at `/`.
|
|
9
|
+
* - Streams updates via Server-Sent Events at `/events`.
|
|
10
|
+
* - Graceful shutdown via `stop()`.
|
|
11
|
+
* - Null-safe error handling throughout.
|
|
12
|
+
*/
|
|
13
|
+
import * as http from "node:http";
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
import * as os from "node:os";
|
|
17
|
+
import * as crypto from "node:crypto";
|
|
18
|
+
import type { AddressInfo } from "node:net";
|
|
19
|
+
|
|
20
|
+
const DEFAULT_REPORT_PATH = path.join(os.homedir(), ".pi", "context-map", "report.html");
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Allowed origins for SSE connections. Only localhost variants are allowed.
|
|
24
|
+
*/
|
|
25
|
+
function isAllowedOrigin(origin: string | undefined, port: number): boolean {
|
|
26
|
+
if (!origin) return true; // No Origin header (e.g., direct curl) is allowed
|
|
27
|
+
const allowed = [
|
|
28
|
+
`http://127.0.0.1:${port}`,
|
|
29
|
+
`http://localhost:${port}`,
|
|
30
|
+
"http://127.0.0.1",
|
|
31
|
+
"http://localhost",
|
|
32
|
+
];
|
|
33
|
+
return allowed.some((o) => origin.startsWith(o));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class LiveReportServer {
|
|
37
|
+
private server: http.Server | null = null;
|
|
38
|
+
private clients: Set<http.ServerResponse> = new Set();
|
|
39
|
+
private currentHtml: string = "";
|
|
40
|
+
private port: number = 0;
|
|
41
|
+
private host: string = "127.0.0.1";
|
|
42
|
+
/** Session token to prevent unauthorized access. */
|
|
43
|
+
public readonly token: string = crypto.randomBytes(16).toString("hex");
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Start the server. Returns a Promise that resolves to the URL, or null on failure.
|
|
47
|
+
*/
|
|
48
|
+
public start(): Promise<string | null> {
|
|
49
|
+
if (this.server) {
|
|
50
|
+
return Promise.resolve(this.url);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return new Promise((resolve) => {
|
|
54
|
+
try {
|
|
55
|
+
this.server = http.createServer((req, res) => this.handleRequest(req, res));
|
|
56
|
+
this.server.on("error", (err) => {
|
|
57
|
+
console.error(`[pi-context-map] Server error: ${err.message}`);
|
|
58
|
+
this.stop();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
this.server.listen(0, this.host, () => {
|
|
62
|
+
const addr = this.server?.address() as AddressInfo | null;
|
|
63
|
+
if (addr) {
|
|
64
|
+
this.port = addr.port;
|
|
65
|
+
console.log(`[pi-context-map] Live server: ${this.url}`);
|
|
66
|
+
resolve(this.url);
|
|
67
|
+
} else {
|
|
68
|
+
resolve(null);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
} catch (err: any) {
|
|
72
|
+
console.error(`[pi-context-map] Failed to start server: ${err.message}`);
|
|
73
|
+
resolve(null);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Stop the server and close all client connections.
|
|
80
|
+
*/
|
|
81
|
+
public stop(): void {
|
|
82
|
+
if (!this.server) return;
|
|
83
|
+
|
|
84
|
+
// Close all SSE clients
|
|
85
|
+
for (const client of this.clients) {
|
|
86
|
+
try {
|
|
87
|
+
client.end();
|
|
88
|
+
} catch (err) {
|
|
89
|
+
// Ignore errors on close
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
this.clients.clear();
|
|
93
|
+
|
|
94
|
+
// Close the server
|
|
95
|
+
this.server.close((err) => {
|
|
96
|
+
if (err) {
|
|
97
|
+
console.error(`[pi-context-map] Error closing server: ${err.message}`);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
this.server = null;
|
|
101
|
+
this.port = 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Update the report content and broadcast to all connected clients.
|
|
106
|
+
* @param html The new HTML content.
|
|
107
|
+
* @param reportPath Optional path to the report file to also write to disk.
|
|
108
|
+
*/
|
|
109
|
+
public update(html: string, reportPath?: string): void {
|
|
110
|
+
this.currentHtml = html;
|
|
111
|
+
|
|
112
|
+
// Optionally write to disk
|
|
113
|
+
if (reportPath) {
|
|
114
|
+
try {
|
|
115
|
+
const dir = path.dirname(reportPath);
|
|
116
|
+
if (!fs.existsSync(dir)) {
|
|
117
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
118
|
+
}
|
|
119
|
+
fs.writeFileSync(reportPath, html, "utf8");
|
|
120
|
+
} catch (err: any) {
|
|
121
|
+
console.error(`[pi-context-map] Failed to write report: ${err.message}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Broadcast to all SSE clients
|
|
126
|
+
for (const client of this.clients) {
|
|
127
|
+
try {
|
|
128
|
+
client.write(`data: ${JSON.stringify({ html, timestamp: Date.now() })}\n\n`);
|
|
129
|
+
} catch (err) {
|
|
130
|
+
// Client may have disconnected; remove it
|
|
131
|
+
this.clients.delete(client);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Get the URL the server is listening on, or null if not started.
|
|
138
|
+
*/
|
|
139
|
+
public get url(): string | null {
|
|
140
|
+
if (!this.server || this.port === 0) return null;
|
|
141
|
+
return `http://${this.host}:${this.port}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Whether the server is currently running.
|
|
146
|
+
*/
|
|
147
|
+
public get isRunning(): boolean {
|
|
148
|
+
return this.server !== null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Handle incoming HTTP requests.
|
|
153
|
+
*/
|
|
154
|
+
private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
155
|
+
if (!req.url) {
|
|
156
|
+
res.writeHead(400);
|
|
157
|
+
res.end("Bad request");
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const url = new URL(req.url, `http://${this.host}:${this.port}`);
|
|
162
|
+
|
|
163
|
+
// SSE endpoint for live updates
|
|
164
|
+
if (url.pathname === "/events") {
|
|
165
|
+
this.handleSSE(req, res);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Health check
|
|
170
|
+
if (url.pathname === "/health") {
|
|
171
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
172
|
+
res.end(JSON.stringify({ status: "ok", port: this.port }));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Stop endpoint
|
|
177
|
+
if (url.pathname === "/stop" && req.method === "POST") {
|
|
178
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
179
|
+
res.end(JSON.stringify({ status: "stopping" }));
|
|
180
|
+
setTimeout(() => this.stop(), 100);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Main page: serve the current HTML or load from disk
|
|
185
|
+
if (url.pathname === "/" || url.pathname === "/report.html") {
|
|
186
|
+
let html = this.currentHtml;
|
|
187
|
+
if (!html) {
|
|
188
|
+
// Try to load from disk as fallback
|
|
189
|
+
try {
|
|
190
|
+
html = fs.readFileSync(DEFAULT_REPORT_PATH, "utf8");
|
|
191
|
+
} catch {
|
|
192
|
+
html = this.placeholderHtml();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// Inject the session token so the client can authenticate to /events
|
|
196
|
+
if (html.includes("<head>")) {
|
|
197
|
+
html = html.replace(
|
|
198
|
+
"<head>",
|
|
199
|
+
`<head><meta name="context-map-token" content="${this.token}">`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
203
|
+
res.end(html);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 404 for everything else
|
|
208
|
+
res.writeHead(404);
|
|
209
|
+
res.end("Not found");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Handle Server-Sent Events connection.
|
|
214
|
+
*/
|
|
215
|
+
private handleSSE(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
216
|
+
// Token-based auth: require ?token=<sessionToken> to prevent unauthorized SSE subscriptions
|
|
217
|
+
if (!req.url) {
|
|
218
|
+
res.writeHead(400);
|
|
219
|
+
res.end("Bad request");
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const reqUrl = new URL(req.url, `http://${this.host}:${this.port}`);
|
|
223
|
+
const providedToken = reqUrl.searchParams.get("token");
|
|
224
|
+
if (providedToken !== this.token) {
|
|
225
|
+
res.writeHead(401, { "Content-Type": "text/plain" });
|
|
226
|
+
res.end("Unauthorized: invalid or missing token");
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Origin validation: only allow connections from localhost
|
|
231
|
+
if (!isAllowedOrigin(req.headers.origin, this.port)) {
|
|
232
|
+
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
233
|
+
res.end("Forbidden: origin not allowed");
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
res.writeHead(200, {
|
|
238
|
+
"Content-Type": "text/event-stream",
|
|
239
|
+
"Cache-Control": "no-cache",
|
|
240
|
+
Connection: "keep-alive",
|
|
241
|
+
"Access-Control-Allow-Origin": `http://127.0.0.1:${this.port}`,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Send initial state if we have content
|
|
245
|
+
if (this.currentHtml) {
|
|
246
|
+
res.write(`data: ${JSON.stringify({ html: this.currentHtml, timestamp: Date.now() })}\n\n`);
|
|
247
|
+
} else {
|
|
248
|
+
res.write(`data: ${JSON.stringify({ waiting: true })}\n\n`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
this.clients.add(res);
|
|
252
|
+
|
|
253
|
+
// Heartbeat to keep connection alive (every 30s)
|
|
254
|
+
const heartbeat = setInterval(() => {
|
|
255
|
+
try {
|
|
256
|
+
res.write(": heartbeat\n\n");
|
|
257
|
+
} catch {
|
|
258
|
+
clearInterval(heartbeat);
|
|
259
|
+
this.clients.delete(res);
|
|
260
|
+
}
|
|
261
|
+
}, 30000);
|
|
262
|
+
|
|
263
|
+
req.on("close", () => {
|
|
264
|
+
clearInterval(heartbeat);
|
|
265
|
+
this.clients.delete(res);
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Placeholder HTML shown when no report has been generated yet.
|
|
271
|
+
*/
|
|
272
|
+
private placeholderHtml(): string {
|
|
273
|
+
return `<!DOCTYPE html>
|
|
274
|
+
<html><head><title>pi-context-map</title>
|
|
275
|
+
<style>body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#010102;color:#f7f8f8;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;}</style>
|
|
276
|
+
</head><body>
|
|
277
|
+
<div style="text-align:center;">
|
|
278
|
+
<h1 style="color:#5e6ad2;font-size:24px;font-weight:600;">pi-context-map</h1>
|
|
279
|
+
<p style="color:#8a8f98;margin-top:8px;">No report generated yet. Run <code>/context-map</code> in Pi to generate one.</p>
|
|
280
|
+
</div>
|
|
281
|
+
</body></html>`;
|
|
282
|
+
}
|
|
283
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-context-map",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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",
|