pi-read-map 1.2.4 → 1.3.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
@@ -2,6 +2,25 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.3.0] - 2026-02-20
6
+
7
+ ### Changed
8
+
9
+ - **Inline file maps**: Maps are now embedded directly in the `read` tool result text instead of being sent as separate `file-map` custom messages. This sacrifices the dedicated collapsible TUI widget but enables **true parallel tool execution**. Previously, custom messages interrupted parallel tool batches, causing skipped reads and forcing slow auto-recovery loops. Inlining guarantees the LLM receives the map immediately in the same turn, drastically speeding up parallel reads and preventing strict API conversation ordering errors (400 Bad Request).
10
+ - Removed the `read-recovery` mechanism since parallel reads are no longer skipped by map generation.
11
+ - Removed `@mariozechner/pi-tui` dependency since custom message rendering is no longer needed.
12
+
13
+ ### Fixed
14
+
15
+ - Directory reads now throw an `EISDIR` error with inline `ls` fallback text reliably. The fallback listing is preserved in thrown errors instead of being swallowed by the internal `try/catch` path.
16
+ - npm tarballs now exclude local scratch artifacts (`*.patch`, `*.orig`, `fix-*.js`, etc.) via `.npmignore`, preventing accidental publication of local debug/review files.
17
+
18
+ ## [1.2.5] - 2026-02-15
19
+
20
+ ### Fixed
21
+
22
+ - `sendMessage` calls in `tool_result` handler now use `deliverAs: "followUp"` instead of the default `"steer"` mode. The default `"steer"` mode interrupts streaming and skips remaining parallel tools — when multiple reads ran concurrently and one triggered a file-map or directory-listing message, the remaining tool calls were skipped, leaving `tool_use` blocks without matching `tool_result` blocks. This caused a 400 error from the Claude API on the next request.
23
+
5
24
  ## [1.2.4] - 2026-02-15
6
25
 
7
26
  ### Fixed
package/README.md CHANGED
@@ -160,13 +160,16 @@ The extension intercepts `read` calls and decides:
160
160
  1. **Binary files** (images, audio, video, archives, etc.): Delegate to built-in read tool
161
161
  2. **Small files** (≤2,000 lines, ≤50 KB): Delegate to built-in read tool
162
162
  3. **Targeted reads** (offset or limit provided): Delegate to built-in read tool
163
- 4. **Large files:**
163
+ 4. **Directory paths**: Run built-in `ls` and throw an `EISDIR` error that includes inline fallback directory output.
164
+ 5. **Large files:**
164
165
  - Call built-in read for the first chunk
165
166
  - Detect language from file extension
166
167
  - Dispatch to a mapper (language-specific → ctags → grep fallback)
167
168
  - Format with budget enforcement
168
169
  - Cache the map
169
- - Send as a separate `file-map` message after `tool_result`
170
+ - Append the map text directly to the read tool's result block
171
+
172
+ *Note on design:* Maps are inlined as raw text rather than sent as separate custom UI messages. While this sacrifices a dedicated TUI widget, it ensures true parallel tool execution. Custom messages interrupt parallel tool batches, causing skipped reads and forcing slow recovery loops. Inlining guarantees the LLM receives the map immediately in the same turn without breaking concurrency.
170
173
 
171
174
  ## Dependencies
172
175
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-read-map",
3
- "version": "1.2.4",
3
+ "version": "1.3.0",
4
4
  "description": "Pi extension that adds structural file maps for large files",
5
5
  "type": "module",
6
6
  "pi": {
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI, Theme } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
2
 
3
3
  import {
4
4
  createReadTool,
@@ -6,20 +6,15 @@ import {
6
6
  DEFAULT_MAX_LINES,
7
7
  DEFAULT_MAX_BYTES,
8
8
  } from "@mariozechner/pi-coding-agent";
9
- import { Text } from "@mariozechner/pi-tui";
10
9
  import { Type } from "@sinclair/typebox";
11
10
  import { exec } from "node:child_process";
12
11
  import { stat } from "node:fs/promises";
13
- import { basename, extname, resolve } from "node:path";
12
+ import { extname, resolve } from "node:path";
14
13
  import { promisify } from "node:util";
15
14
 
16
- import type { FileMapMessageDetails } from "./types.js";
17
-
18
15
  import { formatFileMapWithBudget } from "./formatter.js";
19
16
  import { generateMap, shouldGenerateMap } from "./mapper.js";
20
17
 
21
- export type { FileMapMessageDetails } from "./types.js";
22
-
23
18
  const execAsync = promisify(exec);
24
19
 
25
20
  /**
@@ -79,19 +74,6 @@ const BINARY_EXTENSIONS = new Set([
79
74
  // In-memory cache for maps
80
75
  const mapCache = new Map<string, { mtime: number; map: string }>();
81
76
 
82
- // Pending maps waiting to be sent after tool_result
83
- const pendingMaps = new Map<
84
- string,
85
- {
86
- path: string;
87
- map: string;
88
- details: FileMapMessageDetails;
89
- }
90
- >();
91
-
92
- // Pending directory listings waiting to be sent after a read-on-directory error
93
- const pendingDirectoryLs = new Map<string, { path: string; listing: string }>();
94
-
95
77
  /**
96
78
  * Reset the map cache. Exported for testing purposes only.
97
79
  */
@@ -99,24 +81,6 @@ export function resetMapCache(): void {
99
81
  mapCache.clear();
100
82
  }
101
83
 
102
- /**
103
- * Reset the pending maps. Exported for testing purposes only.
104
- */
105
- export function resetPendingMaps(): void {
106
- pendingMaps.clear();
107
- pendingDirectoryLs.clear();
108
- }
109
-
110
- /**
111
- * Get pending maps for testing inspection.
112
- */
113
- export function getPendingMaps(): Map<
114
- string,
115
- { path: string; map: string; details: FileMapMessageDetails }
116
- > {
117
- return pendingMaps;
118
- }
119
-
120
84
  export default function piReadMapExtension(pi: ExtensionAPI): void {
121
85
  // Get the current working directory
122
86
  const cwd = process.cwd();
@@ -127,160 +91,6 @@ export default function piReadMapExtension(pi: ExtensionAPI): void {
127
91
  // Create the built-in ls tool for directory fallback
128
92
  const builtInLs = createLsTool(cwd);
129
93
 
130
- // Register tool_result handler to send pending maps and directory listings
131
- pi.on("tool_result", (event, _ctx) => {
132
- if (event.toolName !== "read") {
133
- return;
134
- }
135
-
136
- // Send pending directory listing after read-on-directory error
137
- const pendingLs = pendingDirectoryLs.get(event.toolCallId);
138
- if (pendingLs) {
139
- pi.sendMessage({
140
- customType: "directory-listing",
141
- content: `${pendingLs.path} is a directory. Here is ls:\n${pendingLs.listing}`,
142
- display: true,
143
- });
144
- pendingDirectoryLs.delete(event.toolCallId);
145
- }
146
-
147
- const pending = pendingMaps.get(event.toolCallId);
148
- if (!pending) {
149
- return;
150
- }
151
-
152
- // Send the map as a custom message
153
- pi.sendMessage({
154
- customType: "file-map",
155
- content: pending.map,
156
- display: true,
157
- details: pending.details,
158
- });
159
-
160
- // Clean up
161
- pendingMaps.delete(event.toolCallId);
162
- });
163
-
164
- // Register custom message renderer for file-map type
165
- pi.registerMessageRenderer<FileMapMessageDetails>(
166
- "file-map",
167
- (message, options, theme: Theme) => {
168
- const { expanded } = options;
169
- const { details } = message;
170
-
171
- if (expanded) {
172
- // Expanded: show full formatted map
173
- // message.content can be string or array of content blocks
174
- const content =
175
- typeof message.content === "string"
176
- ? message.content
177
- : message.content
178
- .filter((c) => c.type === "text")
179
- .map((c) => (c as { type: "text"; text: string }).text)
180
- .join("\n");
181
- return new Text(content, 0, 0);
182
- }
183
-
184
- // Collapsed: show summary
185
- const fileName = details ? basename(details.filePath) : "file";
186
- const symbolCount = details?.symbolCount ?? 0;
187
- const totalLines = details?.totalLines ?? 0;
188
- const detailLanguage = details?.language ?? "unknown";
189
-
190
- let summary = theme.fg("accent", "📄 File Map: ");
191
- summary += theme.fg("toolTitle", theme.bold(fileName));
192
- summary += theme.fg("muted", ` │ `);
193
- summary += theme.fg("dim", `${symbolCount} symbols`);
194
- summary += theme.fg("muted", ` │ `);
195
- summary += theme.fg("dim", `${totalLines.toLocaleString()} lines`);
196
- summary += theme.fg("muted", ` │ `);
197
- summary += theme.fg("dim", detailLanguage);
198
- summary += theme.fg("muted", ` │ `);
199
- summary += theme.fg("dim", "Ctrl+O to expand");
200
-
201
- return new Text(summary, 0, 0);
202
- }
203
- );
204
-
205
- // Recover from skipped reads caused by map steering
206
- pi.on("turn_end", (event) => {
207
- const SKIPPED_TEXT = "Skipped due to queued user message.";
208
-
209
- // Find skipped read results
210
- const skippedReads = event.toolResults.filter(
211
- (r) =>
212
- r.toolName === "read" &&
213
- !r.isError &&
214
- r.content.some((c) => c.type === "text" && c.text === SKIPPED_TEXT)
215
- );
216
-
217
- if (skippedReads.length === 0) {
218
- return;
219
- }
220
-
221
- // Extract paths from the assistant message's tool calls.
222
- // The message is AgentMessage (union); narrow to AssistantMessage.
223
- const msg = event.message;
224
- if (!("role" in msg) || msg.role !== "assistant") {
225
- return;
226
- }
227
-
228
- const skippedPaths: string[] = [];
229
- for (const skipped of skippedReads) {
230
- const tc = msg.content.find(
231
- (c) =>
232
- c.type === "toolCall" &&
233
- c.name === "read" &&
234
- c.id === skipped.toolCallId
235
- );
236
- if (tc && tc.type === "toolCall" && tc.arguments["path"]) {
237
- skippedPaths.push(String(tc.arguments["path"]));
238
- }
239
- }
240
-
241
- if (skippedPaths.length === 0) {
242
- return;
243
- }
244
-
245
- const pathList = skippedPaths.map((p) => `- read("${p}")`).join("\n");
246
-
247
- pi.sendMessage(
248
- {
249
- customType: "read-recovery",
250
- content: `The following read() calls were interrupted by a file map delivery and need to be completed:\n${pathList}\nPlease re-issue these reads now.`,
251
- display: true,
252
- },
253
- { deliverAs: "followUp" }
254
- );
255
- });
256
-
257
- // Register custom message renderer for read-recovery type
258
- pi.registerMessageRenderer(
259
- "read-recovery",
260
- (message, options, theme: Theme) => {
261
- const content =
262
- typeof message.content === "string"
263
- ? message.content
264
- : message.content
265
- .filter((c) => c.type === "text")
266
- .map((c) => (c as { type: "text"; text: string }).text)
267
- .join("\n");
268
-
269
- if (options.expanded) {
270
- return new Text(content, 0, 0);
271
- }
272
-
273
- const pathCount = (content.match(/^- read\(/gm) || []).length;
274
- let summary = theme.fg("warning", "Recovery: ");
275
- summary += theme.fg(
276
- "dim",
277
- `${pathCount} interrupted read(s) being re-issued`
278
- );
279
-
280
- return new Text(summary, 0, 0);
281
- }
282
- );
283
-
284
94
  // Register our enhanced read tool
285
95
  pi.registerTool({
286
96
  name: "read",
@@ -328,27 +138,34 @@ export default function piReadMapExtension(pi: ExtensionAPI): void {
328
138
  // For non-regular files, handle appropriately
329
139
  if (!stats.isFile()) {
330
140
  if (stats.isDirectory()) {
331
- // Get ls output before letting the error propagate
141
+ // Instead of letting EISDIR propagate and sending a custom steer message,
142
+ // we run ls and embed the listing directly into the thrown error.
143
+ // This prevents steer from breaking parallel executions.
144
+ let lsText: string | null = null;
332
145
  try {
333
146
  const lsResult = await builtInLs.execute(
334
147
  toolCallId,
335
148
  { path: inputPath },
336
149
  signal
337
150
  );
338
- const lsText = lsResult.content
151
+ lsText = lsResult.content
339
152
  .filter(
340
153
  (c): c is { type: "text"; text: string } => c.type === "text"
341
154
  )
342
155
  .map((c) => c.text)
343
156
  .join("\n");
344
- pendingDirectoryLs.set(toolCallId, {
345
- path: absPath,
346
- listing: lsText,
347
- });
348
157
  } catch {
349
158
  // best-effort: if ls fails, just let the error through without listing
350
159
  }
351
- // Delegate to built-in read which will throw EISDIR
160
+
161
+ if (lsText !== null) {
162
+ // eslint-disable-next-line @factory/structured-logging
163
+ throw new Error(
164
+ `EISDIR: illegal operation on a directory, read '${absPath}'\n\nFallback ls output for this directory:\n${lsText}`
165
+ );
166
+ }
167
+
168
+ // Fallback if ls fails for some reason
352
169
  return builtInRead.execute(toolCallId, params, signal, onUpdate);
353
170
  }
354
171
  return builtInRead.execute(toolCallId, params, signal, onUpdate);
@@ -374,8 +191,7 @@ export default function piReadMapExtension(pi: ExtensionAPI): void {
374
191
  return builtInRead.execute(toolCallId, params, signal, onUpdate);
375
192
  }
376
193
 
377
- // File exceeds threshold - generate map
378
- // First, get the built-in result
194
+ // File exceeds threshold - generate map and inline it in the tool result
379
195
  const result = await builtInRead.execute(
380
196
  toolCallId,
381
197
  params,
@@ -383,57 +199,28 @@ export default function piReadMapExtension(pi: ExtensionAPI): void {
383
199
  onUpdate
384
200
  );
385
201
 
386
- // Check cache
387
- const cached = mapCache.get(absPath);
202
+ // Generate or retrieve cached map
388
203
  let mapText: string;
389
- let symbolCount: number;
390
- let language: string;
204
+ const cached = mapCache.get(absPath);
391
205
 
392
206
  if (cached && cached.mtime === stats.mtimeMs) {
393
- // Cache hit - we need to regenerate map for metadata
394
- // (alternatively, cache could store metadata too)
395
- const fileMap = await generateMap(absPath, { signal });
396
- if (fileMap) {
397
- mapText = cached.map;
398
- ({ language } = fileMap);
399
- symbolCount = fileMap.symbols.length;
400
- } else {
401
- return result;
402
- }
207
+ mapText = cached.map;
403
208
  } else {
404
- // Generate new map
405
209
  const fileMap = await generateMap(absPath, { signal });
406
210
 
407
- if (fileMap) {
408
- mapText = formatFileMapWithBudget(fileMap);
409
- ({ language } = fileMap);
410
- symbolCount = fileMap.symbols.length;
411
- // Cache it
412
- mapCache.set(absPath, { mtime: stats.mtimeMs, map: mapText });
413
- } else {
211
+ if (!fileMap) {
414
212
  // Map generation failed, return original result
415
213
  return result;
416
214
  }
417
- }
418
215
 
419
- // Store map in pendingMaps for delivery after tool_result event
420
- pendingMaps.set(toolCallId, {
421
- path: absPath,
422
- map: mapText,
423
- details: {
424
- filePath: absPath,
425
- totalLines,
426
- totalBytes: stats.size,
427
- symbolCount,
428
- language,
429
- },
430
- });
216
+ mapText = formatFileMapWithBudget(fileMap);
217
+ mapCache.set(absPath, { mtime: stats.mtimeMs, map: mapText });
218
+ }
431
219
 
432
- // Return the built-in result unmodified (with cleared truncation details
433
- // since we're providing a map separately)
220
+ // Append map to the tool result content
434
221
  return {
435
222
  ...result,
436
- details: undefined,
223
+ content: [...result.content, { type: "text" as const, text: mapText }],
437
224
  };
438
225
  },
439
226
  });
package/src/types.ts CHANGED
@@ -81,15 +81,3 @@ export interface LanguageInfo {
81
81
  /**
82
82
  * Details for file map custom messages.
83
83
  */
84
- export interface FileMapMessageDetails {
85
- /** Absolute file path */
86
- filePath: string;
87
- /** Total lines in file */
88
- totalLines: number;
89
- /** Total bytes in file */
90
- totalBytes: number;
91
- /** Number of symbols in map */
92
- symbolCount: number;
93
- /** Detected language */
94
- language: string;
95
- }