pi-context-map 0.3.1 → 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 +10 -0
- package/README.md +21 -0
- package/extensions/generator.ts +32 -1
- package/extensions/index.ts +76 -22
- package/extensions/live-server.ts +283 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
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
|
+
|
|
3
13
|
## [0.3.1] - 2026-06-14
|
|
4
14
|
### Design & Interactivity Upgrade
|
|
5
15
|
- **Linear Design System**: Refactored CSS to use the Linear design tokens (canvas #010102, accent #5e6ad2) for a professional, near-black aesthetic.
|
package/README.md
CHANGED
|
@@ -48,6 +48,27 @@ The extension categorizes files to help you manage context bloat:
|
|
|
48
48
|
3. **Categorization**: It calculates the temporal distance between the current turn and the last file access.
|
|
49
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
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
|
+
|
|
51
72
|
## Design
|
|
52
73
|
|
|
53
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.
|
package/extensions/generator.ts
CHANGED
|
@@ -615,6 +615,37 @@ export class ReportGenerator {
|
|
|
615
615
|
|
|
616
616
|
<script>
|
|
617
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
|
+
|
|
618
649
|
// ===== Insight collapse/expand =====
|
|
619
650
|
document.querySelectorAll('.insight-header[data-toggle]').forEach(function(btn) {
|
|
620
651
|
btn.addEventListener('click', function() {
|
|
@@ -630,7 +661,7 @@ export class ReportGenerator {
|
|
|
630
661
|
var grid = document.getElementById('fileGrid');
|
|
631
662
|
var count = document.getElementById('fileCount');
|
|
632
663
|
var empty = document.getElementById('emptyState');
|
|
633
|
-
var cards = Array.prototype.slice.call(grid.querySelectorAll('.file-card'));
|
|
664
|
+
var cards = grid ? Array.prototype.slice.call(grid.querySelectorAll('.file-card')) : [];
|
|
634
665
|
var total = cards.length;
|
|
635
666
|
|
|
636
667
|
function applyFilters() {
|
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,30 +24,53 @@ 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(
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
43
|
-
ctx.ui.notify(`Failed to generate context map: ${message}`, "error");
|
|
61
|
+
const { insights, reportPath } = await runAnalysis();
|
|
62
|
+
const criticalCount = insights.filter((i: any) => i.severity === "critical").length;
|
|
63
|
+
const summary = criticalCount > 0
|
|
64
|
+
? `Context map generated. ${criticalCount} critical insight(s) found.`
|
|
65
|
+
: `Context map generated successfully.`;
|
|
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");
|
|
44
74
|
}
|
|
45
75
|
},
|
|
46
76
|
});
|
|
@@ -48,7 +78,7 @@ export default async function piContextMap(pi: ExtensionAPI) {
|
|
|
48
78
|
pi.registerTool({
|
|
49
79
|
name: "context-map",
|
|
50
80
|
description:
|
|
51
|
-
"Analyze the current session context composition and return actionable insights.",
|
|
81
|
+
"Analyze the current session context composition and return actionable insights. The live localhost report will auto-update.",
|
|
52
82
|
parameters: {
|
|
53
83
|
type: "object",
|
|
54
84
|
properties: {},
|
|
@@ -72,12 +102,14 @@ export default async function piContextMap(pi: ExtensionAPI) {
|
|
|
72
102
|
summaries: composition.summaries.tokens,
|
|
73
103
|
total: composition.total.tokens,
|
|
74
104
|
},
|
|
75
|
-
insights: insights.map((i) => ({
|
|
105
|
+
insights: insights.map((i: any) => ({
|
|
76
106
|
severity: i.severity,
|
|
77
107
|
title: i.title,
|
|
78
108
|
message: i.message,
|
|
79
109
|
command: i.command,
|
|
80
110
|
})),
|
|
111
|
+
liveUrl: serverUrl,
|
|
112
|
+
reportPath: REPORT_PATH,
|
|
81
113
|
};
|
|
82
114
|
} catch (error: any) {
|
|
83
115
|
return { error: error.message };
|
|
@@ -86,7 +118,7 @@ export default async function piContextMap(pi: ExtensionAPI) {
|
|
|
86
118
|
});
|
|
87
119
|
|
|
88
120
|
pi.on("session_before_compact", (event: any, ctx: any) => {
|
|
89
|
-
const tokens =
|
|
121
|
+
const tokens = event?.preparation?.tokensBefore;
|
|
90
122
|
if (tokens && tokens > 100_000) {
|
|
91
123
|
ctx.ui.notify(
|
|
92
124
|
`High context load detected (${(tokens / 1000).toFixed(1)}k tokens). Try /context-map to see what's consuming space.`,
|
|
@@ -94,4 +126,26 @@ export default async function piContextMap(pi: ExtensionAPI) {
|
|
|
94
126
|
);
|
|
95
127
|
}
|
|
96
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
|
+
}
|
|
97
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",
|