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 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 token budget bar and a file-weight grid.
49
+ 4. **Visualization**: It generates a standalone HTML dashboard featuring a stacked composition bar, a file-weight grid with search/filter, and an interactive insights section.
50
+
51
+ ## 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
 
@@ -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
- (insight) => `
41
- <div class="insight-card ${insight.severity}">
42
- <div class="insight-header">
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 Map</title>
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
- --bg: #0f172a;
63
- --card-bg: #1e293b;
64
- --text: #f1f5f9;
65
- --text-dim: #94a3b8;
66
- --primary: #38bdf8;
67
- --active: #22c55e;
68
- --stale: #eab308;
69
- --legacy: #ef4444;
70
- --border: #334155;
71
+ /* Surfaces */
72
+ --canvas: #010102;
73
+ --surface-1: #0f1011;
74
+ --surface-2: #141516;
75
+ --surface-3: #18191a;
76
+ --hairline: #23252a;
77
+ --hairline-strong: #34343a;
78
+
79
+ /* Text */
80
+ --ink: #f7f8f8;
81
+ --ink-muted: #d0d6e0;
82
+ --ink-subtle: #8a8f98;
83
+ --ink-tertiary: #62666d;
84
+
85
+ /* Accent */
86
+ --accent: #5e6ad2;
87
+ --accent-hover: #828fff;
88
+ --accent-soft: rgba(94, 106, 210, 0.12);
89
+
90
+ /* Semantic */
91
+ --success: #27a644;
92
+ --warning: #eab308;
93
+ --danger: #ef4444;
94
+ --warning-soft: rgba(234, 179, 8, 0.10);
95
+ --danger-soft: rgba(239, 68, 68, 0.10);
96
+
97
+ /* Composition segments */
98
+ --seg-system: #6366f1;
99
+ --seg-tools: #ec4899;
100
+ --seg-history: #a855f7;
101
+ --seg-files: #38bdf8;
102
+ --seg-summaries: #14b8a6;
103
+ }
104
+
105
+ * { box-sizing: border-box; margin: 0; padding: 0; }
106
+
107
+ body {
108
+ background: var(--canvas);
109
+ color: var(--ink);
110
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
111
+ font-size: 14px;
112
+ line-height: 1.5;
113
+ -webkit-font-smoothing: antialiased;
114
+ }
115
+
116
+ .container { max-width: 1200px; margin: 0 auto; padding: 48px 32px; }
117
+
118
+ /* ===== Header ===== */
119
+ header { margin-bottom: 48px; }
120
+ h1 {
121
+ font-size: 32px;
122
+ font-weight: 600;
123
+ letter-spacing: -0.8px;
124
+ margin-bottom: 8px;
125
+ color: var(--ink);
126
+ }
127
+ .subtitle { color: var(--ink-subtle); font-size: 14px; margin-bottom: 32px; }
128
+
129
+ .stats-grid {
130
+ display: grid;
131
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
132
+ gap: 16px;
133
+ }
134
+ .stat-card {
135
+ background: var(--surface-1);
136
+ border: 1px solid var(--hairline);
137
+ border-radius: 6px;
138
+ padding: 20px;
139
+ text-align: left;
140
+ }
141
+ .stat-value {
142
+ font-size: 24px;
143
+ font-weight: 600;
144
+ color: var(--ink);
145
+ display: block;
146
+ font-variant-numeric: tabular-nums;
147
+ }
148
+ .stat-label {
149
+ color: var(--ink-subtle);
150
+ font-size: 12px;
151
+ text-transform: uppercase;
152
+ letter-spacing: 0.5px;
153
+ margin-top: 4px;
154
+ display: block;
155
+ }
156
+
157
+ /* ===== Composition Bar ===== */
158
+ .composition-container {
159
+ background: var(--surface-1);
160
+ border: 1px solid var(--hairline);
161
+ border-radius: 6px;
162
+ padding: 20px;
163
+ margin-top: 24px;
164
+ }
165
+ .composition-bar {
166
+ height: 32px;
167
+ background: var(--surface-3);
168
+ border-radius: 4px;
169
+ display: flex;
170
+ overflow: hidden;
171
+ margin-bottom: 12px;
172
+ }
173
+ .composition-segment {
174
+ height: 100%;
175
+ transition: opacity 0.2s ease;
176
+ cursor: default;
177
+ }
178
+ .composition-segment:hover { opacity: 0.85; }
179
+ .seg-system { background: var(--seg-system); }
180
+ .seg-tools { background: var(--seg-tools); }
181
+ .seg-history { background: var(--seg-history); }
182
+ .seg-files { background: var(--seg-files); }
183
+ .seg-summaries { background: var(--seg-summaries); }
184
+
185
+ .composition-legend {
186
+ display: grid;
187
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
188
+ gap: 8px;
189
+ font-size: 12px;
190
+ }
191
+ .legend-item {
192
+ display: flex;
193
+ align-items: center;
194
+ gap: 8px;
195
+ color: var(--ink-muted);
196
+ font-variant-numeric: tabular-nums;
197
+ }
198
+ .dot { width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0; }
199
+
200
+ /* ===== Sections ===== */
201
+ h2 {
202
+ font-size: 20px;
203
+ font-weight: 600;
204
+ color: var(--ink);
205
+ margin: 48px 0 16px;
206
+ letter-spacing: -0.3px;
207
+ }
208
+ h3 {
209
+ font-size: 12px;
210
+ font-weight: 500;
211
+ color: var(--ink-subtle);
212
+ text-transform: uppercase;
213
+ letter-spacing: 0.8px;
214
+ margin-bottom: 12px;
215
+ }
216
+
217
+ /* ===== Insights (shadcn-style cards) ===== */
218
+ .insight-card {
219
+ background: var(--surface-1);
220
+ border: 1px solid var(--hairline);
221
+ border-left: 3px solid var(--accent);
222
+ border-radius: 6px;
223
+ margin-bottom: 8px;
224
+ overflow: hidden;
225
+ }
226
+ .insight-card.critical { border-left-color: var(--danger); background: linear-gradient(90deg, var(--danger-soft) 0%, var(--surface-1) 100%); }
227
+ .insight-card.warning { border-left-color: var(--warning); background: linear-gradient(90deg, var(--warning-soft) 0%, var(--surface-1) 100%); }
228
+ .insight-card.info { border-left-color: var(--accent); }
229
+
230
+ .insight-header {
231
+ display: flex;
232
+ align-items: center;
233
+ gap: 12px;
234
+ padding: 14px 16px;
235
+ cursor: pointer;
236
+ user-select: none;
237
+ background: none;
238
+ border: none;
239
+ width: 100%;
240
+ text-align: left;
241
+ color: inherit;
242
+ font: inherit;
243
+ }
244
+ .insight-header:hover { background: var(--surface-2); }
245
+ .insight-chevron {
246
+ width: 16px;
247
+ height: 16px;
248
+ transition: transform 0.2s ease;
249
+ color: var(--ink-subtle);
250
+ flex-shrink: 0;
251
+ }
252
+ .insight-card.collapsed .insight-chevron { transform: rotate(-90deg); }
253
+ .insight-severity {
254
+ font-size: 10px;
255
+ font-weight: 700;
256
+ padding: 3px 8px;
257
+ border-radius: 3px;
258
+ background: var(--surface-3);
259
+ color: var(--ink-muted);
260
+ letter-spacing: 0.5px;
261
+ flex-shrink: 0;
262
+ }
263
+ .insight-card.critical .insight-severity { color: var(--danger); }
264
+ .insight-card.warning .insight-severity { color: var(--warning); }
265
+ .insight-card.info .insight-severity { color: var(--accent); }
266
+ .insight-title { font-weight: 600; color: var(--ink); font-size: 14px; }
267
+ .insight-body {
268
+ padding: 0 16px 14px 44px;
269
+ color: var(--ink-muted);
270
+ font-size: 13px;
271
+ line-height: 1.6;
272
+ }
273
+ .insight-card.collapsed .insight-body { display: none; }
274
+ .insight-command {
275
+ margin-top: 8px;
276
+ font-size: 12px;
277
+ color: var(--ink-subtle);
278
+ }
279
+ .insight-command code {
280
+ background: var(--surface-3);
281
+ color: var(--accent-hover);
282
+ padding: 2px 6px;
283
+ border-radius: 3px;
284
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
285
+ font-size: 12px;
286
+ }
287
+
288
+ /* ===== File Controls ===== */
289
+ .file-controls {
290
+ display: flex;
291
+ gap: 12px;
292
+ margin-bottom: 16px;
293
+ flex-wrap: wrap;
294
+ }
295
+ .file-search, .file-filter {
296
+ background: var(--surface-1);
297
+ border: 1px solid var(--hairline);
298
+ border-radius: 6px;
299
+ padding: 8px 12px;
300
+ color: var(--ink);
301
+ font: inherit;
302
+ font-size: 13px;
303
+ outline: none;
304
+ transition: border-color 0.15s ease;
305
+ }
306
+ .file-search:focus, .file-filter:focus { border-color: var(--accent); }
307
+ .file-search { flex: 1; min-width: 200px; }
308
+ .file-search::placeholder { color: var(--ink-tertiary); }
309
+ .file-filter { cursor: pointer; }
310
+ .file-count { color: var(--ink-subtle); font-size: 12px; padding: 8px 0; align-self: center; }
311
+
312
+ /* ===== File Grid ===== */
313
+ .file-grid {
314
+ display: grid;
315
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
316
+ gap: 12px;
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
- <div class="file-grid">
279
- ${fileCards}
280
- </div>
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": return "READ";
298
- case "write": return "WRITE";
299
- case "edit": return "EDIT";
300
- case "delete": return "DELETE";
301
- default: return "FILE";
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
 
@@ -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
- const reportPath = ReportGenerator.writeReport(html);
21
- return { composition, insights, reportPath };
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 (_args: any, ctx: any) => {
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 { reportPath, insights } = await runAnalysis();
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
- ctx.ui.notify(`${summary} Path: ${reportPath}`, criticalCount > 0 ? "warning" : "success");
35
- } catch (error) {
36
- const message = error instanceof Error ? error.message : String(error);
37
- ctx.ui.notify(`Failed to generate context map: ${message}`, "error");
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: "Analyze the current session context composition and return actionable insights.",
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 = `Context: ${composition.total.tokens.toLocaleString()} tokens total. ` +
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 = (event as any).preparation?.tokensBefore;
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.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",