pi-context-map 0.4.0 → 0.4.1

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,7 +1,13 @@
1
1
  # Changelog
2
2
 
3
- ## [0.4.0] - 2026-06-14
4
- ### Live Localhost Server
3
+ ## [0.4.1] - 2026-06-15
4
+ ### Critical Fix & Test Suite
5
+ - **Fixed CRASH**: `(pi as any).session?.messages` → now uses event-based message accumulation. `/context-map` no longer crashes with "Cannot read properties of undefined (reading 'messages')".
6
+ - **Fixed Tool Signature**: `registerTool` now uses correct `execute(params, signal, onUpdate, ctx)` signature.
7
+ - **Fixed Import Path**: Uses `pi-coding-agent` (unscoped) instead of `@earendil-works/pi-coding-agent`.
8
+ - **Test Suite**: 34 tests across 5 suites (analyzer, token-counter, insights, generator, live-server).
9
+ - **Type Declarations**: Proper `pi-coding-agent.d.ts` with `ToolDefinition`, `ExtensionCommandContext`, `ExtensionContext`.
10
+ - **Build Clean**: TypeScript strict mode passes with zero errors.
5
11
  - **Live SSE Server**: New `LiveReportServer` binds to 127.0.0.1 on a free port and serves the report at `/`.
6
12
  - **Auto-Updates**: Server-Sent Events endpoint at `/events` pushes the latest HTML whenever the analysis re-runs (e.g., after each assistant message).
7
13
  - **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.
@@ -1,10 +1,14 @@
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
+ * v0.4.1 Fixes session.messages crash, tool registration signature, adds tests.
5
5
  */
6
6
 
7
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
+ import type {
8
+ ExtensionAPI,
9
+ ExtensionCommandContext,
10
+ AgentMessage,
11
+ } from "pi-coding-agent";
8
12
  import { ContextAnalyzer } from "./analyzer";
9
13
  import { ReportGenerator } from "./generator";
10
14
  import { InsightEngine } from "./insights";
@@ -12,30 +16,57 @@ import { LiveReportServer } from "./live-server";
12
16
  import * as path from "node:path";
13
17
  import * as os from "node:os";
14
18
 
15
- const REPORT_PATH = path.join(os.homedir(), ".pi", "context-map", "report.html");
19
+ const REPORT_PATH = path.join(
20
+ os.homedir(),
21
+ ".pi",
22
+ "context-map",
23
+ "report.html",
24
+ );
16
25
 
17
- export default async function piContextMap(pi: ExtensionAPI) {
26
+ export default async function piContextMap(pi: ExtensionAPI): Promise<void> {
18
27
  const analyzer = new ContextAnalyzer();
19
28
  const liveServer = new LiveReportServer();
20
29
 
21
- async function runAnalysis() {
22
- const messages = (pi as any).session?.messages || [];
23
- const currentTurn = messages.length;
30
+ // Accumulate messages from events — this is the correct way to access
31
+ // session messages in Pi. (pi as any).session?.messages does NOT exist.
32
+ let sessionMessages: AgentMessage[] = [];
33
+ let currentTurn = 0;
34
+
35
+ // Capture messages before each LLM call
36
+ pi.on("context", (event: any) => {
37
+ if (event?.messages && Array.isArray(event.messages)) {
38
+ sessionMessages = event.messages;
39
+ }
40
+ });
41
+
42
+ // Track turns
43
+ pi.on("turn_start", () => {
44
+ currentTurn++;
45
+ });
46
+
47
+ async function runAnalysis(): Promise<{
48
+ composition: ReturnType<typeof analyzer.analyzeByType>;
49
+ insights: ReturnType<typeof InsightEngine.generate>;
50
+ reportPath: string;
51
+ }> {
52
+ const messages = sessionMessages.length > 0 ? sessionMessages : [];
24
53
  const composition = analyzer.analyzeByType(messages, currentTurn);
25
54
  const insights = InsightEngine.generate(composition);
26
55
  const html = ReportGenerator.generateHTML(composition, insights);
27
56
 
28
- // Write to disk (for offline access / persistence)
57
+ // Write to disk
29
58
  try {
30
59
  const fs = await import("node:fs");
31
60
  const dir = path.dirname(REPORT_PATH);
32
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
61
+ if (!fs.existsSync(dir)) {
62
+ fs.mkdirSync(dir, { recursive: true });
63
+ }
33
64
  fs.writeFileSync(REPORT_PATH, html, "utf8");
34
65
  } catch (err: any) {
35
- console.error(`[pi-context-map] Failed to write report to disk: ${err.message}`);
66
+ console.error(`[pi-context-map] Failed to write report: ${err.message}`);
36
67
  }
37
68
 
38
- // Push to live server (if running) so the browser updates instantly
69
+ // Push to live server if running
39
70
  if (liveServer.isRunning) {
40
71
  liveServer.update(html, REPORT_PATH);
41
72
  }
@@ -43,14 +74,15 @@ export default async function piContextMap(pi: ExtensionAPI) {
43
74
  return { composition, insights, reportPath: REPORT_PATH };
44
75
  }
45
76
 
46
- // Start the live server on load
77
+ // Start live server
47
78
  const serverUrl = await liveServer.start();
48
79
 
80
+ // Register /context-map command
49
81
  pi.registerCommand("context-map", {
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") {
82
+ description:
83
+ "Generate a visual context map with actionable insights. Use 'stop' to terminate the live server.",
84
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
85
+ if (args.trim().toLowerCase() === "stop") {
54
86
  liveServer.stop();
55
87
  ctx.ui.notify("Live server stopped.", "info");
56
88
  return;
@@ -59,66 +91,85 @@ export default async function piContextMap(pi: ExtensionAPI) {
59
91
  ctx.ui.notify("Analyzing session context...", "info");
60
92
  try {
61
93
  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
-
94
+ const criticalCount = insights.filter(
95
+ (i) => i.severity === "critical",
96
+ ).length;
97
+ const summary =
98
+ criticalCount > 0
99
+ ? `Context map generated. ${criticalCount} critical insight(s) found.`
100
+ : "Context map generated successfully.";
67
101
  let details = `File: ${reportPath}`;
68
102
  if (serverUrl) {
69
- details += ` Live: ${serverUrl}`;
103
+ details += ` | Live: ${serverUrl}`;
70
104
  }
71
- ctx.ui.notify(`${summary} ${details}`, criticalCount > 0 ? "warning" : "success");
105
+ ctx.ui.notify(
106
+ `${summary} ${details}`,
107
+ criticalCount > 0 ? "warning" : "success",
108
+ );
72
109
  } catch (error: any) {
73
- ctx.ui.notify(`Failed to generate context map: ${error.message}`, "error");
110
+ ctx.ui.notify(
111
+ `Failed to generate context map: ${error.message}`,
112
+ "error",
113
+ );
74
114
  }
75
115
  },
76
116
  });
77
117
 
118
+ // Register the tool for agent use
78
119
  pi.registerTool({
79
120
  name: "context-map",
121
+ label: "Context Map",
80
122
  description:
81
123
  "Analyze the current session context composition and return actionable insights. The live localhost report will auto-update.",
82
124
  parameters: {
83
125
  type: "object",
84
126
  properties: {},
85
127
  },
86
- handler: async (_ctx: any, _args: any) => {
128
+ async execute(
129
+ _params: any,
130
+ _signal: AbortSignal | undefined,
131
+ _onUpdate: ((u: any) => void) | undefined,
132
+ _ctx: any,
133
+ ) {
87
134
  try {
88
135
  const { composition, insights } = await runAnalysis();
136
+ const usagePercent =
137
+ composition.total.tokens > 0
138
+ ? Math.round((composition.total.tokens / 128_000) * 100)
139
+ : 0;
89
140
  const summary =
90
141
  `Context: ${composition.total.tokens.toLocaleString()} tokens total. ` +
91
142
  `System ${composition.system.percent}%, Tools ${composition.tools.percent}%, ` +
92
143
  `History ${composition.history.percent}%, Files ${composition.files.percent}%, ` +
93
144
  `Summaries ${composition.summaries.percent}%. ` +
145
+ `Usage: ${usagePercent}% of typical 128k window. ` +
94
146
  `${insights.length} insight(s) generated.`;
95
147
  return {
96
- summary,
97
- composition: {
98
- system: composition.system.tokens,
99
- tools: composition.tools.tokens,
100
- history: composition.history.tokens,
101
- files: composition.files.tokens,
102
- summaries: composition.summaries.tokens,
103
- total: composition.total.tokens,
104
- },
105
- insights: insights.map((i: any) => ({
106
- severity: i.severity,
107
- title: i.title,
108
- message: i.message,
109
- command: i.command,
110
- })),
111
- liveUrl: serverUrl,
112
- reportPath: REPORT_PATH,
148
+ type: "text" as const,
149
+ content: [
150
+ summary,
151
+ "",
152
+ ...insights.map(
153
+ (i) => `[${i.severity.toUpperCase()}] ${i.title}: ${i.message}`,
154
+ ),
155
+ serverUrl ? `Live report: ${serverUrl}` : "",
156
+ ]
157
+ .filter(Boolean)
158
+ .join("\n"),
113
159
  };
114
160
  } catch (error: any) {
115
- return { error: error.message };
161
+ return {
162
+ type: "text" as const,
163
+ content: `Error: ${error.message}`,
164
+ isError: true,
165
+ };
116
166
  }
117
167
  },
118
168
  });
119
169
 
120
- pi.on("session_before_compact", (event: any, ctx: any) => {
121
- const tokens = event?.preparation?.tokensBefore;
170
+ // Auto-warning on high context before compaction
171
+ pi.on("session_before_compact", (_event: any, ctx: any) => {
172
+ const tokens = _event?.preparation?.tokensBefore;
122
173
  if (tokens && tokens > 100_000) {
123
174
  ctx.ui.notify(
124
175
  `High context load detected (${(tokens / 1000).toFixed(1)}k tokens). Try /context-map to see what's consuming space.`,
@@ -127,9 +178,9 @@ export default async function piContextMap(pi: ExtensionAPI) {
127
178
  }
128
179
  });
129
180
 
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) {
181
+ // Auto-refresh after each assistant message if server is running
182
+ pi.on("message_end", async (_event: any) => {
183
+ if (_event?.message?.role === "assistant" && liveServer.isRunning) {
133
184
  try {
134
185
  await runAnalysis();
135
186
  } catch {
@@ -138,14 +189,15 @@ export default async function piContextMap(pi: ExtensionAPI) {
138
189
  }
139
190
  });
140
191
 
141
- // Graceful shutdown: stop the live server when the session ends
192
+ // Graceful shutdown
142
193
  pi.on("session_shutdown", () => {
143
194
  liveServer.stop();
144
195
  });
145
196
 
146
- // Log the live URL once on startup
147
197
  if (serverUrl) {
148
198
  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.`);
199
+ console.log(
200
+ `[pi-context-map] Run /context-map to generate a report, or /context-map stop to terminate.`,
201
+ );
150
202
  }
151
203
  }
@@ -17,7 +17,12 @@ import * as os from "node:os";
17
17
  import * as crypto from "node:crypto";
18
18
  import type { AddressInfo } from "node:net";
19
19
 
20
- const DEFAULT_REPORT_PATH = path.join(os.homedir(), ".pi", "context-map", "report.html");
20
+ const DEFAULT_REPORT_PATH = path.join(
21
+ os.homedir(),
22
+ ".pi",
23
+ "context-map",
24
+ "report.html",
25
+ );
21
26
 
22
27
  /**
23
28
  * Allowed origins for SSE connections. Only localhost variants are allowed.
@@ -52,7 +57,9 @@ export class LiveReportServer {
52
57
 
53
58
  return new Promise((resolve) => {
54
59
  try {
55
- this.server = http.createServer((req, res) => this.handleRequest(req, res));
60
+ this.server = http.createServer((req, res) =>
61
+ this.handleRequest(req, res),
62
+ );
56
63
  this.server.on("error", (err) => {
57
64
  console.error(`[pi-context-map] Server error: ${err.message}`);
58
65
  this.stop();
@@ -69,7 +76,9 @@ export class LiveReportServer {
69
76
  }
70
77
  });
71
78
  } catch (err: any) {
72
- console.error(`[pi-context-map] Failed to start server: ${err.message}`);
79
+ console.error(
80
+ `[pi-context-map] Failed to start server: ${err.message}`,
81
+ );
73
82
  resolve(null);
74
83
  }
75
84
  });
@@ -118,14 +127,18 @@ export class LiveReportServer {
118
127
  }
119
128
  fs.writeFileSync(reportPath, html, "utf8");
120
129
  } catch (err: any) {
121
- console.error(`[pi-context-map] Failed to write report: ${err.message}`);
130
+ console.error(
131
+ `[pi-context-map] Failed to write report: ${err.message}`,
132
+ );
122
133
  }
123
134
  }
124
135
 
125
136
  // Broadcast to all SSE clients
126
137
  for (const client of this.clients) {
127
138
  try {
128
- client.write(`data: ${JSON.stringify({ html, timestamp: Date.now() })}\n\n`);
139
+ client.write(
140
+ `data: ${JSON.stringify({ html, timestamp: Date.now() })}\n\n`,
141
+ );
129
142
  } catch (err) {
130
143
  // Client may have disconnected; remove it
131
144
  this.clients.delete(client);
@@ -151,7 +164,10 @@ export class LiveReportServer {
151
164
  /**
152
165
  * Handle incoming HTTP requests.
153
166
  */
154
- private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
167
+ private handleRequest(
168
+ req: http.IncomingMessage,
169
+ res: http.ServerResponse,
170
+ ): void {
155
171
  if (!req.url) {
156
172
  res.writeHead(400);
157
173
  res.end("Bad request");
@@ -243,7 +259,9 @@ export class LiveReportServer {
243
259
 
244
260
  // Send initial state if we have content
245
261
  if (this.currentHtml) {
246
- res.write(`data: ${JSON.stringify({ html: this.currentHtml, timestamp: Date.now() })}\n\n`);
262
+ res.write(
263
+ `data: ${JSON.stringify({ html: this.currentHtml, timestamp: Date.now() })}\n\n`,
264
+ );
247
265
  } else {
248
266
  res.write(`data: ${JSON.stringify({ waiting: true })}\n\n`);
249
267
  }
@@ -1,3 +1,101 @@
1
- declare module "@earendil-works/pi-coding-agent" {
2
- export type ExtensionAPI = any;
1
+ declare module "pi-coding-agent" {
2
+ export interface ExtensionAPI {
3
+ on(event: string, handler: Function): void;
4
+ registerTool<TDetails = unknown>(tool: ToolDefinition<TDetails>): void;
5
+ registerCommand(
6
+ name: string,
7
+ options: {
8
+ description?: string;
9
+ handler: (
10
+ args: string,
11
+ ctx: ExtensionCommandContext,
12
+ ) => Promise<void> | void;
13
+ },
14
+ ): void;
15
+ registerProvider(name: string, config: any): void;
16
+ unregisterProvider(name: string): void;
17
+ sendMessage(message: any, options?: any): void;
18
+ sendUserMessage(content: string | any[], options?: any): void;
19
+ appendEntry(customType: string, data?: any): void;
20
+ setSessionName(name: string): void;
21
+ getSessionName(): string | undefined;
22
+ setLabel(entryId: string, label: string | undefined): void;
23
+ getActiveTools(): string[];
24
+ getAllTools(): any[];
25
+ setActiveTools(toolNames: string[]): void;
26
+ }
27
+
28
+ export interface ToolDefinition<TDetails = unknown> {
29
+ name: string;
30
+ label: string;
31
+ description: string;
32
+ promptSnippet?: string;
33
+ promptGuidelines?: string[];
34
+ parameters: any;
35
+ execute(
36
+ params: any,
37
+ signal: AbortSignal | undefined,
38
+ onUpdate: ((update: TDetails) => void) | undefined,
39
+ ctx: ExtensionContext,
40
+ ): Promise<{ type: "text"; content: string; isError?: boolean }>;
41
+ renderCall?: (args: any, theme: any) => any;
42
+ renderResult?: (result: any, options: any, theme: any) => any;
43
+ }
44
+
45
+ export interface ExtensionContext {
46
+ ui: ExtensionUIContext;
47
+ hasUI: boolean;
48
+ cwd: string;
49
+ sessionManager: any;
50
+ model: any;
51
+ isIdle(): boolean;
52
+ abort(): void;
53
+ shutdown(): void;
54
+ getContextUsage():
55
+ | { tokens: number | null; contextWindow: number; percent: number | null }
56
+ | undefined;
57
+ compact(options?: any): void;
58
+ getSystemPrompt(): string;
59
+ }
60
+
61
+ export interface ExtensionUIContext {
62
+ notify(
63
+ message: string,
64
+ type?: "info" | "warning" | "error" | "success",
65
+ ): void;
66
+ }
67
+
68
+ export interface ExtensionCommandContext {
69
+ ui: ExtensionUIContext;
70
+ hasUI: boolean;
71
+ cwd: string;
72
+ sessionManager: any;
73
+ model: any;
74
+ isIdle(): boolean;
75
+ abort(): void;
76
+ shutdown(): void;
77
+ getContextUsage(): any;
78
+ compact(options?: any): void;
79
+ getSystemPrompt(): string;
80
+ waitForIdle(): Promise<void>;
81
+ newSession(options?: any): Promise<{ cancelled: boolean }>;
82
+ fork(entryId: string): Promise<{ cancelled: boolean }>;
83
+ navigateTree(
84
+ targetId: string,
85
+ options?: any,
86
+ ): Promise<{ cancelled: boolean }>;
87
+ switchSession(sessionPath: string): Promise<{ cancelled: boolean }>;
88
+ reload(): Promise<void>;
89
+ }
90
+
91
+ export interface AgentMessage {
92
+ role: "user" | "assistant" | "system" | "tool";
93
+ content?: any;
94
+ id?: string;
95
+ name?: string;
96
+ tool_call_id?: string;
97
+ type?: string;
98
+ compactionEntry?: any;
99
+ timestamp?: number;
100
+ }
3
101
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-context-map",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
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",